programgarden 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {programgarden-2.2.0 → programgarden-2.3.0}/PKG-INFO +3 -3
  2. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/context.py +5 -50
  3. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/deep_fixtures.py +0 -13
  4. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/executor.py +5 -371
  5. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_external.py +7 -2
  6. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_sources.py +1 -39
  7. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/validation_recommender.py +0 -1
  8. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/validation_replay.py +14 -1
  9. {programgarden-2.2.0 → programgarden-2.3.0}/pyproject.toml +3 -3
  10. {programgarden-2.2.0 → programgarden-2.3.0}/README.md +0 -0
  11. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/__init__.py +0 -0
  12. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/binding_validator.py +0 -0
  13. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/client.py +0 -0
  14. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/code_worker.py +0 -0
  15. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/__init__.py +0 -0
  16. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/adjustment_delivery.py +0 -0
  17. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/broker_evidence.py +0 -0
  18. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/broker_order_totals.py +0 -0
  19. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/broker_snapshot.py +0 -0
  20. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/checkpoint_manager.py +0 -0
  21. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/db_naming.py +0 -0
  22. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/execution_storage.py +0 -0
  23. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/fill_reconciler.py +0 -0
  24. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/order_cancellation.py +0 -0
  25. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/order_recovery.py +0 -0
  26. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/owned_order_cancellation.py +0 -0
  27. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/position_reconciliation.py +0 -0
  28. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/query_builder.py +0 -0
  29. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/workflow_position_tracker.py +0 -0
  30. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  31. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/futures_orderable.py +0 -0
  32. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/futures_pnl.py +0 -0
  33. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/futures_read_evidence.py +0 -0
  34. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/incremental_build.py +0 -0
  35. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/managed_order_control.py +0 -0
  36. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/node_runner.py +0 -0
  37. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/order_lifecycle.py +0 -0
  38. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/plugin/__init__.py +0 -0
  39. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/plugin/sandbox.py +0 -0
  40. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/providers/__init__.py +0 -0
  41. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/providers/llm_errors.py +0 -0
  42. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/providers/llm_provider.py +0 -0
  43. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/reconnect_handler.py +0 -0
  44. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_contracts.py +0 -0
  45. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_events.py +0 -0
  46. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_order_adapter.py +0 -0
  47. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_orders.py +0 -0
  48. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_scenarios.py +0 -0
  49. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_semantics.py +0 -0
  50. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_sqlite.py +0 -0
  51. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/replay_triggers.py +0 -0
  52. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resolver.py +0 -0
  53. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resource/__init__.py +0 -0
  54. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resource/context.py +0 -0
  55. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resource/limiter.py +0 -0
  56. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resource/monitor.py +0 -0
  57. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/resource/throttle.py +0 -0
  58. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/semantic_rules.py +0 -0
  59. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/__init__.py +0 -0
  60. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/credential_tools.py +0 -0
  61. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/definition_tools.py +0 -0
  62. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/event_tools.py +0 -0
  63. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/job_tools.py +0 -0
  64. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/registry_tools.py +0 -0
  65. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/tools/sqlite_tools.py +0 -0
  66. {programgarden-2.2.0 → programgarden-2.3.0}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -15,8 +15,8 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
15
15
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
- Requires-Dist: programgarden-community (>=2.1.0,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.2.0,<3.0.0)
18
+ Requires-Dist: programgarden-community (>=2.2.0,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.3.0,<3.0.0)
20
20
  Requires-Dist: programgarden-finance (>=2.0.1,<3.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
@@ -236,11 +236,6 @@ class ExecutionContext:
236
236
  # Realtime data buffer
237
237
  self._realtime_data: Dict[str, Any] = {}
238
238
 
239
- # Market status cache (JIF 기반, MarketStatusNode 전용)
240
- # key=market canonical name (e.g. "US"), value=dict(jstatus, label,
241
- # is_open, is_regular_open, is_extended_open, updated_at, jangubun)
242
- self._market_status_cache: Dict[str, Dict[str, Any]] = {}
243
-
244
239
  # Providers
245
240
  self._data_provider: Optional[DataProvider] = None
246
241
  self._account_provider: Optional[AccountProvider] = None
@@ -796,47 +791,6 @@ class ExecutionContext:
796
791
  """Get realtime data"""
797
792
  return self._realtime_data.get(key)
798
793
 
799
- # === Market Status (JIF-based) ===
800
-
801
- def update_market_status(self, market: str, status: Dict[str, Any]) -> None:
802
- """Update the cached status snapshot for a canonical market key.
803
-
804
- Called by MarketStatusNodeExecutor on each JIF event. ``market``
805
- is one of the 12 canonical keys (KOSPI, US, HK_AM, ...). Unknown
806
- codes are stored as-is so debugging can still inspect them.
807
- """
808
-
809
- self._market_status_cache[market] = status
810
-
811
- def get_market_status(self, market: str) -> Optional[Dict[str, Any]]:
812
- """Return the latest status snapshot for ``market`` or None if
813
- no JIF event has been received yet for that market."""
814
-
815
- return self._market_status_cache.get(market)
816
-
817
- def is_market_open(
818
- self, market: str, include_extended: bool = False
819
- ) -> Optional[bool]:
820
- """Convenience helper for ``{{ ... is_market_open("US") }}``.
821
-
822
- Returns ``None`` when no event has been received for the market
823
- (distinct from ``False``, which means an event said the market
824
- is closed). Honours the ``include_extended`` flag to cover
825
- pre/after-market sessions.
826
- """
827
-
828
- status = self._market_status_cache.get(market)
829
- if not status:
830
- return None
831
- if include_extended:
832
- return bool(status.get("is_extended_open", False))
833
- return bool(status.get("is_regular_open", False))
834
-
835
- def get_all_market_statuses(self) -> Dict[str, Dict[str, Any]]:
836
- """Shallow copy of the full market status snapshot cache."""
837
-
838
- return dict(self._market_status_cache)
839
-
840
794
  # === Data Query (Provider Delegation) ===
841
795
 
842
796
  async def get_price(self, symbol: str) -> Optional[float]:
@@ -3395,10 +3349,11 @@ class ExecutionContext:
3395
3349
  Also provides `input` variable for workflow inputs.
3396
3350
 
3397
3351
  Available variables in expressions:
3398
- - {{ nodeId.port }}: Node output values
3399
- - {{ nodes.nodeId.method() }}: Node output with method chaining
3400
- - {{ item }}, {{ item.field }}: Current iteration item
3401
- - {{ index }}, {{ total }}: Iteration context
3352
+ - {{ nodes.nodeId.port }}: Node output values (the `nodes.` prefix is
3353
+ required — a bare `{{ nodeId.port }}` is an undefined variable)
3354
+ - {{ nodes.nodeId.method() }}: Node output with method chaining
3355
+ - {{ item }}, {{ item.field }}: Current iteration item
3356
+ - {{ index }}, {{ total }}: Iteration context
3402
3357
  - {{ input.xxx }}: Workflow input parameters
3403
3358
  - {{ context.xxx }}: Runtime context parameters
3404
3359
  """
@@ -441,19 +441,6 @@ def futures_contract_fixture(
441
441
  }
442
442
 
443
443
 
444
- def market_status_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
445
- """MarketStatusNode deep fixture (markets open).
446
-
447
- Real shape: ``{"statuses": [{market, status}]}``.
448
- """
449
- return {
450
- "statuses": [
451
- {"market": "NASDAQ", "status": "OPEN"},
452
- {"market": "NYSE", "status": "OPEN"},
453
- ],
454
- }
455
-
456
-
457
444
  def broker_connection_fixture(
458
445
  node_type: str,
459
446
  config: Dict[str, Any],
@@ -9635,356 +9635,6 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
9635
9635
  context.log("error", f"Korea stock order event error: {e}", node_id)
9636
9636
  return {"error": str(e)}
9637
9637
 
9638
- class MarketStatusNodeExecutor(NodeExecutorBase):
9639
- """MarketStatusNode executor — JIF 기반 실시간 시장 상태 추적.
9640
-
9641
- Broker credential type 무관(해외주식/해외선물/국내주식 중 아무 BrokerNode).
9642
- broker의 기존 WSS 세션과 별도로 ``Common.real()`` 공용 WebSocket 세션을
9643
- 생성하여 JIF TR 하나를 구독합니다. token_manager 는 broker 로그인에서
9644
- 재사용하므로 중복 로그인 없음.
9645
-
9646
- stay_connected=True (기본): 구독 유지하며 콜백마다 context._market_status_cache
9647
- + set_output 갱신. 워크플로우 종료 시 cleanup_jif_subscriptions 에서 해제.
9648
-
9649
- stay_connected=False: 최대 5초 기다리며 수신된 이벤트 반영한 snapshot 반환
9650
- 후 즉시 tr_type=4 해제. AI Agent Tool 일회성 조회용.
9651
- """
9652
-
9653
- # {f"{job_id}::{node_id}": {"jif": RealJIF, "real": Real, "stay_connected": bool}}
9654
- _active_subscriptions: Dict[str, Any] = {}
9655
-
9656
- # Canonical market list for the convenience boolean ports. Updated in
9657
- # lock-step with the OutputPort definitions on ``MarketStatusNode``.
9658
- _SOLO_MARKET_PORTS = (
9659
- ("US", "us_is_open"),
9660
- ("KOSPI", "kospi_is_open"),
9661
- ("KOSDAQ", "kosdaq_is_open"),
9662
- ("KRX_FUTURES", "krx_futures_is_open"),
9663
- )
9664
-
9665
- _COMBO_MARKET_PORTS = (
9666
- ("hk_is_open", "HK_AM", "HK_PM"),
9667
- ("cn_is_open", "CN_AM", "CN_PM"),
9668
- ("jp_is_open", "JP_AM", "JP_PM"),
9669
- )
9670
-
9671
- async def cleanup_jif_subscriptions(self, job_id: str) -> None:
9672
- """Unsubscribe every active JIF stream registered for the given job.
9673
-
9674
- Mirrors ``BrokerNodeExecutor.cleanup_fill_subscriptions`` so the
9675
- executor's shutdown sequence can wipe market-status subscriptions
9676
- without touching unrelated real-time feeds.
9677
- """
9678
-
9679
- keys_to_remove = []
9680
- for key, info in self._active_subscriptions.items():
9681
- if not key.startswith(f"{job_id}::"):
9682
- continue
9683
- jif = info.get("jif")
9684
- real = info.get("real")
9685
- if jif is not None:
9686
- try:
9687
- jif.on_remove_jif_message()
9688
- except (RuntimeError, AttributeError):
9689
- pass
9690
- except Exception as e:
9691
- logger.warning(f"JIF unsubscribe failed for {key}: {e}")
9692
- if real is not None:
9693
- try:
9694
- await real.close()
9695
- except Exception as e:
9696
- logger.warning(f"Common.real close failed for {key}: {e}")
9697
- keys_to_remove.append(key)
9698
-
9699
- for key in keys_to_remove:
9700
- del self._active_subscriptions[key]
9701
- logger.debug(f"Cleaned up JIF subscription: {key}")
9702
-
9703
- async def execute(
9704
- self,
9705
- node_id: str,
9706
- node_type: str,
9707
- config: Dict[str, Any],
9708
- context: ExecutionContext,
9709
- **kwargs,
9710
- ) -> Dict[str, Any]:
9711
- import asyncio
9712
- from datetime import datetime
9713
-
9714
- from programgarden_core.exceptions import ConnectionError, ValidationError
9715
-
9716
- # deep_validate: return a fixture market-status (markets open) so the
9717
- # flow keeps flowing instead of skipping (no WebSocket/JIF subscription).
9718
- if context.is_deep_validate:
9719
- from programgarden import deep_fixtures as _df
9720
- config = evaluate_all_bindings(config, context, node_id)
9721
- fixture = _df.market_status_fixture(config)
9722
- override = context.get_deep_fixture(node_id, node_type)
9723
- return _df.apply_override(fixture, override)
9724
-
9725
- # dry_run skip — WebSocket 불가
9726
- if context.is_dry_run:
9727
- context.log(
9728
- "warning",
9729
- f"[dry_run] MarketStatusNode skipped (realtime WebSocket disabled)",
9730
- node_id,
9731
- )
9732
- return {"status": "skipped_dry_run", "dry_run": True}
9733
-
9734
- # 1. broker_connection 획득
9735
- # MarketStatusNode 는 product_scope=ALL 이므로 auto-injection 에서 제외.
9736
- # 여기서 직접 find_parent_output(...) 로 아무 BrokerNode 나 수용.
9737
- broker_connection = config.get("connection")
9738
- if not broker_connection:
9739
- for broker_type in (
9740
- "OverseasStockBrokerNode",
9741
- "KoreaStockBrokerNode",
9742
- "OverseasFuturesBrokerNode",
9743
- ):
9744
- candidates = context.find_parent_outputs(node_id, broker_type)
9745
- if candidates:
9746
- _bnode_id, _dist, outputs = candidates[0]
9747
- broker_connection = outputs.get("connection")
9748
- if broker_connection:
9749
- context.log(
9750
- "debug",
9751
- f"MarketStatusNode: broker_connection resolved from "
9752
- f"{broker_type}/{_bnode_id} (distance={_dist})",
9753
- node_id,
9754
- )
9755
- break
9756
-
9757
- if not broker_connection:
9758
- raise ConnectionError(
9759
- "MarketStatusNode: BrokerNode 연결이 필요합니다. "
9760
- "OverseasStockBrokerNode / OverseasFuturesBrokerNode / "
9761
- "KoreaStockBrokerNode 중 아무거나 상위에 배치하세요."
9762
- )
9763
-
9764
- # 2. LS 로그인 (broker 와 동일 instance 재사용)
9765
- # 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
9766
- appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
9767
- paper_trading = bool(paper_trading)
9768
- broker_product = broker_connection.get("product", "overseas_stock")
9769
-
9770
- ls, success, error = ensure_ls_login(
9771
- appkey, appsecret, paper_trading, context, node_id, broker_product,
9772
- caller_name="MarketStatusNode",
9773
- )
9774
- if not success:
9775
- raise ConnectionError(f"MarketStatusNode LS login failed: {error}")
9776
-
9777
- # 3. Common.real() — broker WSS 와 독립된 공용 세션
9778
- from programgarden_finance.ls.common import Common
9779
-
9780
- common = Common(token_manager=ls.token_manager)
9781
- real = common.real()
9782
- await real.connect()
9783
- context.log(
9784
- "info",
9785
- f"JIF WebSocket connected via Common.real (paper_trading={paper_trading})",
9786
- node_id,
9787
- )
9788
-
9789
- # 4. 구독 설정
9790
- markets_filter = config.get("markets", []) or []
9791
- stay_connected = bool(config.get("stay_connected", True))
9792
- include_extended = bool(config.get("include_extended_hours", False))
9793
- trigger_nodes = config.get("_trigger_on_update_nodes", [])
9794
-
9795
- jif = real.JIF()
9796
- loop = asyncio.get_running_loop()
9797
-
9798
- # Track subscription for cleanup (registered even for stay_connected=False
9799
- # so that an error between subscribe and unsubscribe still gets cleaned up)
9800
- sub_key = f"{context.job_id}::{node_id}"
9801
- MarketStatusNodeExecutor._active_subscriptions[sub_key] = {
9802
- "jif": jif,
9803
- "real": real,
9804
- "stay_connected": stay_connected,
9805
- }
9806
-
9807
- def _is_open(market: str) -> bool:
9808
- flag = context.is_market_open(market, include_extended=include_extended)
9809
- return bool(flag) if flag is not None else False
9810
-
9811
- def _compute_conv_booleans() -> Dict[str, bool]:
9812
- conv: Dict[str, bool] = {}
9813
- for market, port_name in self._SOLO_MARKET_PORTS:
9814
- conv[port_name] = _is_open(market)
9815
- for port_name, am_key, pm_key in self._COMBO_MARKET_PORTS:
9816
- conv[port_name] = bool(_is_open(am_key) or _is_open(pm_key))
9817
- return conv
9818
-
9819
- def on_jif_message(resp):
9820
- """JIFRealResponse 수신 콜백 — cache/output/notification 일괄 갱신."""
9821
-
9822
- try:
9823
- if context.is_shutdown:
9824
- return
9825
- body = getattr(resp, "body", None)
9826
- if body is None:
9827
- # ACK payload (rsp_cd only). Ignore.
9828
- return
9829
-
9830
- jangubun = str(getattr(body, "jangubun", "") or "")
9831
- jstatus = str(getattr(body, "jstatus", "") or "")
9832
- if not jangubun:
9833
- return
9834
-
9835
- # Canonical market key + open-state derivations (core 레이어)
9836
- from programgarden_core.nodes.market_status import (
9837
- JANGUBUN_TO_MARKET,
9838
- is_extended_open,
9839
- is_regular_open,
9840
- )
9841
- from programgarden_finance.ls.common.real.JIF.constants import (
9842
- resolve_jstatus,
9843
- )
9844
-
9845
- market = JANGUBUN_TO_MARKET.get(jangubun, jangubun)
9846
-
9847
- # markets 필터 (설정됐으면 구독 자체가 서버측 제약은 아니므로
9848
- # 여기서 클라이언트 필터링 적용)
9849
- if markets_filter and market not in markets_filter:
9850
- return
9851
-
9852
- status_info = resolve_jstatus(jstatus)
9853
- reg_open = is_regular_open(jstatus)
9854
- ext_open = is_extended_open(jstatus)
9855
-
9856
- new_status = {
9857
- "market": market,
9858
- "jangubun": jangubun,
9859
- "jstatus": jstatus,
9860
- "jstatus_label": status_info["label"],
9861
- "is_regular_open": reg_open,
9862
- "is_extended_open": ext_open,
9863
- "is_open": ext_open if include_extended else reg_open,
9864
- "updated_at": datetime.now().isoformat(),
9865
- }
9866
-
9867
- # 이전 상태 조회 (transition 감지용)
9868
- prev_status = context.get_market_status(market)
9869
- prev_jstatus = prev_status.get("jstatus") if prev_status else None
9870
- prev_label = prev_status.get("jstatus_label") if prev_status else None
9871
- is_transition = prev_jstatus is not None and prev_jstatus != jstatus
9872
-
9873
- # Cache update
9874
- context.update_market_status(market, new_status)
9875
-
9876
- # Output update — 바인딩 리졸버가 이 값을 읽음
9877
- all_statuses = list(context.get_all_market_statuses().values())
9878
- outputs = {
9879
- "statuses": all_statuses,
9880
- "event": {
9881
- "market": market,
9882
- "jstatus": jstatus,
9883
- "jstatus_label": status_info["label"],
9884
- "prev_jstatus": prev_jstatus,
9885
- "prev_jstatus_label": prev_label,
9886
- "transitioned_at": new_status["updated_at"],
9887
- },
9888
- **_compute_conv_booleans(),
9889
- }
9890
-
9891
- for port_name, port_value in outputs.items():
9892
- context.set_output(node_id, port_name, port_value)
9893
-
9894
- # SSE broadcast
9895
- asyncio.run_coroutine_threadsafe(
9896
- context.notify_output_update(
9897
- node_id=node_id,
9898
- node_type="MarketStatusNode",
9899
- outputs=outputs,
9900
- ),
9901
- loop,
9902
- )
9903
-
9904
- # 전이 notification (prev != new)
9905
- if is_transition:
9906
- asyncio.run_coroutine_threadsafe(
9907
- context.send_notification(
9908
- category=NotificationCategory.SCHEDULE_STARTED,
9909
- severity=NotificationSeverity.INFO,
9910
- title=f"Market status change: {market}",
9911
- message=(
9912
- f"{market}: {prev_label or prev_jstatus} → "
9913
- f"{status_info['label']} "
9914
- f"(jstatus {prev_jstatus} → {jstatus})"
9915
- ),
9916
- node_id=node_id,
9917
- node_type="MarketStatusNode",
9918
- data={
9919
- "market": market,
9920
- "jangubun": jangubun,
9921
- "prev_jstatus": prev_jstatus,
9922
- "jstatus": jstatus,
9923
- "jstatus_label": status_info["label"],
9924
- "is_regular_open": reg_open,
9925
- "is_extended_open": ext_open,
9926
- },
9927
- ),
9928
- loop,
9929
- )
9930
-
9931
- # Trigger downstream nodes
9932
- if trigger_nodes:
9933
- asyncio.run_coroutine_threadsafe(
9934
- context.emit_event(
9935
- event_type="market_status",
9936
- source_node_id=node_id,
9937
- data=outputs,
9938
- trigger_nodes=trigger_nodes,
9939
- ),
9940
- loop,
9941
- )
9942
- except Exception as e:
9943
- context.log("warning", f"JIF message parse error: {e}", node_id)
9944
-
9945
- jif.on_jif_message(on_jif_message)
9946
- context.log(
9947
- "info",
9948
- f"JIF subscription started "
9949
- f"(markets={markets_filter or 'all 12'}, "
9950
- f"stay_connected={stay_connected}, "
9951
- f"include_extended_hours={include_extended})",
9952
- node_id,
9953
- )
9954
-
9955
- # One-shot 모드: 5초 대기 → 수신된 이벤트 반영 후 해제
9956
- if not stay_connected:
9957
- wait_timeout = 5.0
9958
- check_interval = 0.1
9959
- deadline = loop.time() + wait_timeout
9960
- while loop.time() < deadline:
9961
- if context.get_all_market_statuses():
9962
- break
9963
- await asyncio.sleep(check_interval)
9964
-
9965
- try:
9966
- jif.on_remove_jif_message()
9967
- except Exception:
9968
- pass
9969
- MarketStatusNodeExecutor._active_subscriptions.pop(sub_key, None)
9970
- try:
9971
- await real.close()
9972
- except Exception:
9973
- pass
9974
-
9975
- # 초기/최종 output (stay_connected=True 에서는 현재까지 수신된 snapshot,
9976
- # False 에서는 해제 직전 snapshot 을 그대로 반환)
9977
- initial_statuses = list(context.get_all_market_statuses().values())
9978
- initial_outputs: Dict[str, Any] = {
9979
- "statuses": initial_statuses,
9980
- "event": None,
9981
- **_compute_conv_booleans(),
9982
- }
9983
- for port_name, port_value in initial_outputs.items():
9984
- context.set_output(node_id, port_name, port_value)
9985
-
9986
- return initial_outputs
9987
-
9988
9638
 
9989
9639
  class DisplayNodeExecutor(NodeExecutorBase):
9990
9640
  """
@@ -12574,7 +12224,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12574
12224
  "interval": config.get("interval", "1d"),
12575
12225
  }
12576
12226
 
12577
- # 기간 설정 ({{ today_yyyymmdd() }}, {{ days_ago_yyyymmdd(100) }} 바인딩 사용)
12227
+ # 기간 설정 ({{ date.today(format='yyyymmdd') }}, {{ date.ago(100, format='yyyymmdd') }} 바인딩 사용)
12578
12228
  start_date = config.get("start_date", "")
12579
12229
  end_date = config.get("end_date", "")
12580
12230
  interval = config.get("interval", "1d") # 1d, 1w, 1m, 1min 등
@@ -20670,10 +20320,6 @@ class WorkflowExecutor:
20670
20320
  # Data nodes
20671
20321
  "SQLiteNode": SQLiteNodeExecutor(),
20672
20322
  "CodeNode": CodeNodeExecutor(), # custom Python code (subprocess-isolated)
20673
- # External market data nodes (credential 불필요, 외부 API)
20674
- "CurrencyRateNode": GenericNodeExecutor(),
20675
- # Market Status (JIF, broker credential type 무관)
20676
- "MarketStatusNode": MarketStatusNodeExecutor(),
20677
20323
  # AI nodes
20678
20324
  "LLMModelNode": LLMModelNodeExecutor(),
20679
20325
  "AIAgentNode": AIAgentNodeExecutor(),
@@ -21775,8 +21421,6 @@ class WorkflowJob:
21775
21421
  finally:
21776
21422
  # Cleanup broker fill subscriptions (SDK 콜백 해제)
21777
21423
  await self._cleanup_broker_fill_subscriptions()
21778
- # Cleanup MarketStatusNode JIF subscriptions
21779
- await self._cleanup_jif_subscriptions()
21780
21424
  # Cleanup persistent nodes (shutdown flag 설정 + 구독 해제 + WebSocket close)
21781
21425
  await self.context.cleanup_persistent_nodes()
21782
21426
  # Cleanup listeners
@@ -21932,7 +21576,7 @@ class WorkflowJob:
21932
21576
  config["_source_node_id"] = edge.from_node_id
21933
21577
  break # 첫 번째 상위 노드만 사용
21934
21578
 
21935
- # Resolve expressions in config ({{ input.xxx }}, {{ nodeId.port }})
21579
+ # Resolve expressions in config ({{ input.xxx }}, {{ nodes.nodeId.port }})
21936
21580
  config = self._resolve_config_expressions(config, node_id)
21937
21581
 
21938
21582
  # Auto-inject connection from matching BrokerNode (Phase 5)
@@ -22349,7 +21993,7 @@ class WorkflowJob:
22349
21993
  # 브로커/외부 API 제한**을 모델링한 것이라, 호출 자체가 없는 dry_run 모의 노드에는
22350
21994
  # 순수 낭비다 — 실측: 모의 주문 16건 × 5초 = 75초가 60초 빌드 타임아웃을 넘겼다
22351
21995
  # (prod 대화 f2eed93c, 2026-08-28). dry_run 에서도 **실제 호출하는** 노드
22352
- # (HTTPRequestNode 1초·CurrencyRateNode 30초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
21996
+ # (HTTPRequestNode 1초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
22353
21997
  DRY_RUN_SIMULATED_NODE_TYPES = frozenset({
22354
21998
  # 주문 — NewOrderNodeExecutor / ModifyOrderNodeExecutor / CancelOrderNodeExecutor
22355
21999
  "OverseasStockNewOrderNode", "OverseasStockModifyOrderNode", "OverseasStockCancelOrderNode",
@@ -23653,7 +23297,8 @@ class WorkflowJob:
23653
23297
 
23654
23298
  지원 표현식:
23655
23299
  - {{ input.xxx }}: 워크플로우 inputs 파라미터
23656
- - {{ nodeId.port }}: 이전 노드 출력값
23300
+ - {{ nodes.nodeId.port }}: 이전 노드 출력값 (`nodes.` 접두사 필수 — 접두사 없는
23301
+ `{{ nodeId.port }}` 는 정의되지 않은 변수 오류)
23657
23302
  - {{ context.xxx }}: 실행 컨텍스트 값
23658
23303
 
23659
23304
  Note: items 키는 제외 (ConditionNode의 _process_items_with_extract에서 별도 처리).
@@ -24280,14 +23925,6 @@ class WorkflowJob:
24280
23925
  except Exception as e:
24281
23926
  logger.warning(f"Failed to cleanup broker fill subscriptions: {e}")
24282
23927
 
24283
- async def _cleanup_jif_subscriptions(self) -> None:
24284
- """MarketStatusNode의 JIF 구독 SDK 콜백 정리."""
24285
- try:
24286
- ms_executor = MarketStatusNodeExecutor()
24287
- await ms_executor.cleanup_jif_subscriptions(self.job_id)
24288
- except Exception as e:
24289
- logger.warning(f"Failed to cleanup JIF subscriptions: {e}")
24290
-
24291
23928
  async def stop(self) -> None:
24292
23929
  """Stop execution gracefully"""
24293
23930
  logger.info(f"Stopping job {self.job_id}")
@@ -24318,7 +23955,6 @@ class WorkflowJob:
24318
23955
  # 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
24319
23956
  # 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
24320
23957
  await self._cleanup_broker_fill_subscriptions()
24321
- await self._cleanup_jif_subscriptions()
24322
23958
  await self.context.cleanup_persistent_nodes()
24323
23959
  await self.context.cleanup_flow_end_nodes()
24324
23960
 
@@ -24357,7 +23993,6 @@ class WorkflowJob:
24357
23993
 
24358
23994
  # Cleanup
24359
23995
  await self._cleanup_broker_fill_subscriptions()
24360
- await self._cleanup_jif_subscriptions()
24361
23996
  await self.context.cleanup_persistent_nodes()
24362
23997
  await self.context.cleanup_flow_end_nodes()
24363
23998
 
@@ -24422,7 +24057,6 @@ class WorkflowJob:
24422
24057
 
24423
24058
  # 모든 리소스 정리
24424
24059
  await self._cleanup_broker_fill_subscriptions()
24425
- await self._cleanup_jif_subscriptions()
24426
24060
  await self.context.cleanup_persistent_nodes()
24427
24061
  await self.context.cleanup_flow_end_nodes()
24428
24062
 
@@ -111,8 +111,13 @@ def request_identity(node_type, config):
111
111
  raise ContractViolation("request", "Invalid resolved recording request", "REPLAY_FIXTURE_INVALID") from exc
112
112
  # Reject unknown parameters instead of letting BaseModel's extra policy
113
113
  # silently remove a field the live executor may consume.
114
- if set(config) - set(node_class.model_fields) - {"connection"} - _SCHEDULER_FIELDS - _PRESENTATION_FIELDS:
115
- raise ContractViolation("request", "Unknown recording request fields", "REPLAY_FIXTURE_INVALID")
114
+ unknown = sorted(set(config) - set(node_class.model_fields) - {"connection"} - _SCHEDULER_FIELDS - _PRESENTATION_FIELDS)
115
+ if unknown:
116
+ # Name the keys: a builder that wrote `fields: ["price"]` on a market-data node
117
+ # (dev 5d9961de, 2026-09-25) retried the same config twice on the bare message.
118
+ accepted = sorted(set(node_class.model_fields) - _PRESENTATION_FIELDS - {"id", "type", "name", "description", "position", "category"})
119
+ raise ContractViolation("request", "Unknown recording request fields: " + ", ".join(unknown)
120
+ + " (" + node_type + " accepts: " + ", ".join(accepted) + ")", "REPLAY_FIXTURE_INVALID")
116
121
  result = node.model_dump(mode="json", exclude=_PRESENTATION_FIELDS | _SCHEDULER_FIELDS | {"connection"})
117
122
  connection = config.get("connection")
118
123
  if connection is not None:
@@ -4,13 +4,12 @@ These records are scenario assumptions, not evidence of provider availability.
4
4
  The independent suite owns the inputs. No adapter reads a live file or transport.
5
5
  """
6
6
  from copy import deepcopy
7
- from pathlib import PurePosixPath
8
7
 
9
8
  from programgarden.replay_contracts import ContractViolation, check_contract
10
9
  from programgarden.replay_external import external_record
11
10
  from programgarden.replay_triggers import fixture_instant
12
11
 
13
- SOURCE_NODES = frozenset({"FileReaderNode", "FearGreedIndexNode", "MarketUniverseNode",
12
+ SOURCE_NODES = frozenset({"MarketUniverseNode",
14
13
  "FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode"})
15
14
 
16
15
 
@@ -30,16 +29,6 @@ def source_contract_catalog():
30
29
  "type": "array", "items": {"type": "object", "properties": {
31
30
  "google": {"type": "string"}, "currency": {"type": "string"}}}}}}
32
31
  return {
33
- "FearGreedIndexNode": {"source_schema": {"type": "object", "required": ["fear_and_greed"],
34
- "properties": {"fear_and_greed": {"type": "object", "required": ["score"], "properties": {
35
- "score": {"type": "number", "minimum": 0, "maximum": 100},
36
- "previous_close": {"type": "number"}}}}},
37
- "notes": "Native parser maps score to value/label and reads previous_close. Synthetic inputs only."},
38
- "FileReaderNode": {"source_schema": {"type": "object", "properties": {"files": {
39
- "type": "object", "additionalProperties": {"type": "string"}}}},
40
- "notes": "files maps each requested relative upload path to base64 encoded bytes. The native "
41
- "format parser reads those bytes; do not supply parsed data. Inline file_data uses "
42
- "native configuration. Absolute paths and parent traversal are forbidden."},
43
32
  "MarketUniverseNode": {"source_schema": {"type": "object", "required": ["index", "stocks"],
44
33
  "properties": {"index": {"type": "string"}, "stocks": {"type": "array", "items": stock}}},
45
34
  "index_mapping": dict(MarketUniverseNodeExecutor.INDEX_MAPPING),
@@ -92,33 +81,6 @@ async def execute_source(node, config, fixture, context):
92
81
  source = deepcopy(record["source"])
93
82
  if not isinstance(source, dict):
94
83
  raise ContractViolation(node.id, "Source envelope must be an object")
95
- if node.type == "FearGreedIndexNode":
96
- check_contract(source, {"type": "object", "required": ["fear_and_greed"], "properties": {
97
- "fear_and_greed": {"type": "object", "required": ["score"], "properties": {
98
- "score": {"type": "number", "minimum": 0, "maximum": 100}}}}}, node.id)
99
- return node.parse_response(source)
100
- if node.type == "FileReaderNode":
101
- # Materialize the exact recorded bytes through the native base64 path.
102
- # Preserve format/name selection; never read /app/data or a host path.
103
- paths, data, names = node._normalize_inputs()
104
- files = source.get("files", {})
105
- for index, path in enumerate(paths):
106
- if not path:
107
- continue
108
- parsed = PurePosixPath(path)
109
- if parsed.is_absolute() or ".." in parsed.parts:
110
- raise ContractViolation(node.id, "Replay file paths must stay relative to the upload directory")
111
- if path not in files or not isinstance(files[path], str):
112
- raise ContractViolation(node.id, "Requested file has no recorded bytes", "REPLAY_FIXTURE_REQUIRED")
113
- while len(data) <= index:
114
- data.append("")
115
- while len(names) <= index:
116
- names.append("")
117
- data[index] = files[path]
118
- names[index] = names[index] or PurePosixPath(path).name
119
- local = node.model_copy(update={"file_paths": [], "file_path": None,
120
- "file_data_list": data, "file_data": None, "file_names": names, "file_name": None})
121
- return await local.execute(context)
122
84
  if node.type == "MarketUniverseNode":
123
85
  from programgarden.executor import MarketUniverseNodeExecutor
124
86
  executor = MarketUniverseNodeExecutor()
@@ -72,7 +72,6 @@ ACCOUNT_NODES: Set[str] = {
72
72
  EXTERNAL_API_NODES: Set[str] = {
73
73
  "HTTPRequestNode",
74
74
  "TelegramNode",
75
- "FearGreedIndexNode",
76
75
  }
77
76
 
78
77
  AGGREGATE_NODES: Set[str] = {"AggregateNode", "FieldMappingNode", "ConditionNode", "SplitNode"}
@@ -39,7 +39,7 @@ FIXTURE_NODES = frozenset({
39
39
  for kind in ("Broker", "Account", "RealAccount", "MarketData", "HistoricalData",
40
40
  "Fundamental", "SymbolQuery", "OpenOrders", "RealMarketData", "RealOrderEvent")
41
41
  if not (product == "OverseasFutures" and kind == "Fundamental")
42
- }) | {"HTTPRequestNode", "LLMModelNode", "AIAgentNode", "MarketStatusNode", "CurrencyRateNode", "TelegramNode"}
42
+ }) | {"HTTPRequestNode", "LLMModelNode", "AIAgentNode", "TelegramNode"}
43
43
 
44
44
 
45
45
  def content_hash(value: Any) -> str:
@@ -251,6 +251,19 @@ class ReplayExecutor(WorkflowExecutor):
251
251
  raise ContractViolation(node_id,"Completion evidence requires an existing request and broker observation node")
252
252
  self.orders.apply_events(record["order_events"], node_type)
253
253
  output = deepcopy(record["output"])
254
+ if isinstance(output, dict):
255
+ # The live executor returns its ports in declaration order and the
256
+ # auto-iteration fallback source is a node's FIRST output; a recording
257
+ # written as {"count": 1, "open_orders": [...]} must not make replay pick
258
+ # `count` where live picks the list (dev 5d9961de, 2026-09-25).
259
+ from programgarden_core import NodeTypeRegistry
260
+ try:
261
+ declared = [port["name"] for port in NodeTypeRegistry().get_schema(node_type).outputs]
262
+ except Exception: # noqa: BLE001 — unknown schema keeps the recorded order
263
+ declared = []
264
+ ordered = {name: output[name] for name in declared if name in output}
265
+ ordered.update({k: v for k, v in output.items() if k not in ordered})
266
+ output = ordered
254
267
  check_contract(output, record["contract"], f"{node_id}.output")
255
268
  if node_type.endswith("OpenOrdersNode"):
256
269
  # The snapshot SHAPE (unique non-empty order_ids, count == rows)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.2.0"
8
+ version = "2.3.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,9 +36,9 @@ litellm = ">=1.40.0"
36
36
  # 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
37
37
  # (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
38
38
  # 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
39
- programgarden-core = "^2.2.0"
39
+ programgarden-core = "^2.3.0"
40
40
  programgarden-finance = "^2.0.1"
41
- programgarden-community = "^2.1.0"
41
+ programgarden-community = "^2.2.0"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]
44
44
  pytest = "^8.0.0"
File without changes