programgarden 2.2.0__tar.gz → 2.4.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.4.0}/PKG-INFO +3 -3
  2. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/context.py +5 -50
  3. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/deep_fixtures.py +0 -13
  4. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/executor.py +15 -377
  5. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_events.py +7 -1
  6. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_external.py +7 -2
  7. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_semantics.py +7 -4
  8. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_sources.py +1 -39
  9. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_triggers.py +7 -2
  10. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/validation_recommender.py +0 -1
  11. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/validation_replay.py +14 -1
  12. {programgarden-2.2.0 → programgarden-2.4.0}/pyproject.toml +3 -3
  13. {programgarden-2.2.0 → programgarden-2.4.0}/README.md +0 -0
  14. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/__init__.py +0 -0
  15. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/binding_validator.py +0 -0
  16. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/client.py +0 -0
  17. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/code_worker.py +0 -0
  18. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/__init__.py +0 -0
  19. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/adjustment_delivery.py +0 -0
  20. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_evidence.py +0 -0
  21. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_order_totals.py +0 -0
  22. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_snapshot.py +0 -0
  23. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/checkpoint_manager.py +0 -0
  24. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/db_naming.py +0 -0
  25. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/execution_storage.py +0 -0
  26. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/fill_reconciler.py +0 -0
  27. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/order_cancellation.py +0 -0
  28. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/order_recovery.py +0 -0
  29. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/owned_order_cancellation.py +0 -0
  30. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/position_reconciliation.py +0 -0
  31. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/query_builder.py +0 -0
  32. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/workflow_position_tracker.py +0 -0
  33. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_orderable.py +0 -0
  35. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_pnl.py +0 -0
  36. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_read_evidence.py +0 -0
  37. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/incremental_build.py +0 -0
  38. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/managed_order_control.py +0 -0
  39. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/node_runner.py +0 -0
  40. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/order_lifecycle.py +0 -0
  41. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/plugin/__init__.py +0 -0
  42. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/plugin/sandbox.py +0 -0
  43. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/__init__.py +0 -0
  44. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/llm_errors.py +0 -0
  45. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/llm_provider.py +0 -0
  46. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/reconnect_handler.py +0 -0
  47. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_contracts.py +0 -0
  48. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_order_adapter.py +0 -0
  49. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_orders.py +0 -0
  50. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_scenarios.py +0 -0
  51. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_sqlite.py +0 -0
  52. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resolver.py +0 -0
  53. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/__init__.py +0 -0
  54. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/context.py +0 -0
  55. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/limiter.py +0 -0
  56. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/monitor.py +0 -0
  57. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/throttle.py +0 -0
  58. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/semantic_rules.py +0 -0
  59. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/__init__.py +0 -0
  60. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/credential_tools.py +0 -0
  61. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/definition_tools.py +0 -0
  62. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/event_tools.py +0 -0
  63. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/job_tools.py +0 -0
  64. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/registry_tools.py +0 -0
  65. {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/sqlite_tools.py +0 -0
  66. {programgarden-2.2.0 → programgarden-2.4.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.4.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.4.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],
@@ -2253,8 +2253,10 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2253
2253
  cron_expr = cron_expr.strip()
2254
2254
  tz_name = config.get("timezone", "America/New_York")
2255
2255
  enabled = config.get("enabled", True)
2256
- count = config.get("count", 1000) # 최대 반복 횟수 (M-6: 기본값 축소; 모델 필드로 편입)
2257
- max_duration_hours = config.get("max_duration_hours", 24.0)
2256
+ # None/absent = unbounded: run until the workflow is stopped (owner
2257
+ # 2026-09-26). A provided value is enforced as a cap in the loop below.
2258
+ count = config.get("count") # None → no cycle cap
2259
+ max_duration_hours = config.get("max_duration_hours") # None → no wall-clock cap
2258
2260
 
2259
2261
  if not enabled:
2260
2262
  context.log("info", f"Schedule disabled: {cron_expr}", node_id)
@@ -2289,7 +2291,8 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2289
2291
  import time as _time
2290
2292
  cnt = 0
2291
2293
  start_mono = _time.monotonic()
2292
- max_duration_sec = max_duration_hours * 3600
2294
+ # None = unbounded; only compute a wall-clock cap when a bound was set.
2295
+ max_duration_sec = max_duration_hours * 3600 if max_duration_hours is not None else None
2293
2296
  try:
2294
2297
  # second_at_beginning=True로 초 단위 cron도 지원
2295
2298
  try:
@@ -2297,7 +2300,7 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2297
2300
  except TypeError:
2298
2301
  itr = croniter(cron_expr, datetime.now(tz))
2299
2302
 
2300
- while cnt < count and context.is_running:
2303
+ while (count is None or cnt < count) and context.is_running:
2301
2304
  # dry_run: 루프 진입 즉시 1회 emit 후 종료
2302
2305
  if context.is_dry_run:
2303
2306
  cnt += 1
@@ -2317,8 +2320,9 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2317
2320
  },
2318
2321
  )
2319
2322
  break
2320
- # M-6: max_duration_hours 초과 시 스케줄 종료
2321
- if (_time.monotonic() - start_mono) >= max_duration_sec:
2323
+ # M-6: max_duration_hours 초과 시 스케줄 종료 (한도가 설정된 경우에만;
2324
+ # None 이면 사용자가 워크플로우를 멈출 때까지 계속 실행)
2325
+ if max_duration_sec is not None and (_time.monotonic() - start_mono) >= max_duration_sec:
2322
2326
  context.log(
2323
2327
  "warning",
2324
2328
  f"Schedule max_duration_hours={max_duration_hours}h 초과 - "
@@ -9635,356 +9639,6 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
9635
9639
  context.log("error", f"Korea stock order event error: {e}", node_id)
9636
9640
  return {"error": str(e)}
9637
9641
 
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
9642
 
9989
9643
  class DisplayNodeExecutor(NodeExecutorBase):
9990
9644
  """
@@ -12574,7 +12228,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12574
12228
  "interval": config.get("interval", "1d"),
12575
12229
  }
12576
12230
 
12577
- # 기간 설정 ({{ today_yyyymmdd() }}, {{ days_ago_yyyymmdd(100) }} 바인딩 사용)
12231
+ # 기간 설정 ({{ date.today(format='yyyymmdd') }}, {{ date.ago(100, format='yyyymmdd') }} 바인딩 사용)
12578
12232
  start_date = config.get("start_date", "")
12579
12233
  end_date = config.get("end_date", "")
12580
12234
  interval = config.get("interval", "1d") # 1d, 1w, 1m, 1min 등
@@ -20670,10 +20324,6 @@ class WorkflowExecutor:
20670
20324
  # Data nodes
20671
20325
  "SQLiteNode": SQLiteNodeExecutor(),
20672
20326
  "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
20327
  # AI nodes
20678
20328
  "LLMModelNode": LLMModelNodeExecutor(),
20679
20329
  "AIAgentNode": AIAgentNodeExecutor(),
@@ -21775,8 +21425,6 @@ class WorkflowJob:
21775
21425
  finally:
21776
21426
  # Cleanup broker fill subscriptions (SDK 콜백 해제)
21777
21427
  await self._cleanup_broker_fill_subscriptions()
21778
- # Cleanup MarketStatusNode JIF subscriptions
21779
- await self._cleanup_jif_subscriptions()
21780
21428
  # Cleanup persistent nodes (shutdown flag 설정 + 구독 해제 + WebSocket close)
21781
21429
  await self.context.cleanup_persistent_nodes()
21782
21430
  # Cleanup listeners
@@ -21932,7 +21580,7 @@ class WorkflowJob:
21932
21580
  config["_source_node_id"] = edge.from_node_id
21933
21581
  break # 첫 번째 상위 노드만 사용
21934
21582
 
21935
- # Resolve expressions in config ({{ input.xxx }}, {{ nodeId.port }})
21583
+ # Resolve expressions in config ({{ input.xxx }}, {{ nodes.nodeId.port }})
21936
21584
  config = self._resolve_config_expressions(config, node_id)
21937
21585
 
21938
21586
  # Auto-inject connection from matching BrokerNode (Phase 5)
@@ -22349,7 +21997,7 @@ class WorkflowJob:
22349
21997
  # 브로커/외부 API 제한**을 모델링한 것이라, 호출 자체가 없는 dry_run 모의 노드에는
22350
21998
  # 순수 낭비다 — 실측: 모의 주문 16건 × 5초 = 75초가 60초 빌드 타임아웃을 넘겼다
22351
21999
  # (prod 대화 f2eed93c, 2026-08-28). dry_run 에서도 **실제 호출하는** 노드
22352
- # (HTTPRequestNode 1초·CurrencyRateNode 30초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
22000
+ # (HTTPRequestNode 1초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
22353
22001
  DRY_RUN_SIMULATED_NODE_TYPES = frozenset({
22354
22002
  # 주문 — NewOrderNodeExecutor / ModifyOrderNodeExecutor / CancelOrderNodeExecutor
22355
22003
  "OverseasStockNewOrderNode", "OverseasStockModifyOrderNode", "OverseasStockCancelOrderNode",
@@ -23653,7 +23301,8 @@ class WorkflowJob:
23653
23301
 
23654
23302
  지원 표현식:
23655
23303
  - {{ input.xxx }}: 워크플로우 inputs 파라미터
23656
- - {{ nodeId.port }}: 이전 노드 출력값
23304
+ - {{ nodes.nodeId.port }}: 이전 노드 출력값 (`nodes.` 접두사 필수 — 접두사 없는
23305
+ `{{ nodeId.port }}` 는 정의되지 않은 변수 오류)
23657
23306
  - {{ context.xxx }}: 실행 컨텍스트 값
23658
23307
 
23659
23308
  Note: items 키는 제외 (ConditionNode의 _process_items_with_extract에서 별도 처리).
@@ -24280,14 +23929,6 @@ class WorkflowJob:
24280
23929
  except Exception as e:
24281
23930
  logger.warning(f"Failed to cleanup broker fill subscriptions: {e}")
24282
23931
 
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
23932
  async def stop(self) -> None:
24292
23933
  """Stop execution gracefully"""
24293
23934
  logger.info(f"Stopping job {self.job_id}")
@@ -24318,7 +23959,6 @@ class WorkflowJob:
24318
23959
  # 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
24319
23960
  # 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
24320
23961
  await self._cleanup_broker_fill_subscriptions()
24321
- await self._cleanup_jif_subscriptions()
24322
23962
  await self.context.cleanup_persistent_nodes()
24323
23963
  await self.context.cleanup_flow_end_nodes()
24324
23964
 
@@ -24357,7 +23997,6 @@ class WorkflowJob:
24357
23997
 
24358
23998
  # Cleanup
24359
23999
  await self._cleanup_broker_fill_subscriptions()
24360
- await self._cleanup_jif_subscriptions()
24361
24000
  await self.context.cleanup_persistent_nodes()
24362
24001
  await self.context.cleanup_flow_end_nodes()
24363
24002
 
@@ -24422,7 +24061,6 @@ class WorkflowJob:
24422
24061
 
24423
24062
  # 모든 리소스 정리
24424
24063
  await self._cleanup_broker_fill_subscriptions()
24425
- await self._cleanup_jif_subscriptions()
24426
24064
  await self.context.cleanup_persistent_nodes()
24427
24065
  await self.context.cleanup_flow_end_nodes()
24428
24066
 
@@ -128,7 +128,13 @@ async def replay_events(job, runner, fixture, outcome):
128
128
  if now != expected:
129
129
  raise ContractViolation(source_id, "Recorded tick is not the next configured cron instant")
130
130
  count = schedule_counts.get(source_id, 0) + 1
131
- if count > config.get("count", 1000) or (now - origin).total_seconds() > config.get("max_duration_hours", 24.0) * 3600:
131
+ # count / max_duration_hours are optional (None/absent = unbounded,
132
+ # run until the workflow is stopped). Only enforce a bound that was set.
133
+ cfg_count = config.get("count")
134
+ cfg_max_dur = config.get("max_duration_hours")
135
+ if (cfg_count is not None and count > cfg_count) or (
136
+ cfg_max_dur is not None and (now - origin).total_seconds() > cfg_max_dur * 3600
137
+ ):
132
138
  raise ContractViolation(source_id, "Recorded ticks exceed schedule safety limits")
133
139
  schedule_instants[source_id], schedule_counts[source_id] = now, count
134
140
  else:
@@ -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:
@@ -349,8 +349,9 @@ def _time_rules(node_type: str, role: str) -> List[Dict[str, Any]]:
349
349
  {"id": "timezone_iana", "field": "timezone",
350
350
  "default": "America/New_York", "invalid": "replay_rejects"},
351
351
  {"id": "disabled_emits_nothing", "field": "enabled"},
352
- {"id": "limits", "count": {"default": 1000, "min": 1},
353
- "max_duration_hours": {"default": 24.0, "gt": 0}, "pin": "range_not_const"},
352
+ {"id": "limits", "optional": True, "absent_means": "unbounded",
353
+ "count": {"default": None, "min": 1},
354
+ "max_duration_hours": {"default": None, "gt": 0}, "pin": "range_not_const"},
354
355
  ]
355
356
  if node_type == "SessionGateNode":
356
357
  return [{"id": "evaluate_at_fixture_instant"}] # validation_replay.py:276-283
@@ -607,8 +608,10 @@ def _schedule_time_lines(lines: List[str]) -> None:
607
608
  lines.append("A recorded tick must equal the cron's next firing instant after "
608
609
  "the prior frame in this timezone: a whole minute, at least one "
609
610
  "minute later.")
610
- lines.append("count and max_duration_hours are safety limits (defaults 1000 and "
611
- "24.0); a contract bounds them with a range, never a const.")
611
+ lines.append("count and max_duration_hours are optional safety limits; omit both "
612
+ "and the schedule runs until the workflow is stopped. When provided, "
613
+ "count must be >= 1 and max_duration_hours > 0, and a contract bounds "
614
+ "them with a range, never a const.")
612
615
  lines.append("Set the cron time in the target market's timezone. Market "
613
616
  "operating hours by product:")
614
617
  pe = product_execution()
@@ -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()
@@ -49,8 +49,13 @@ async def schedule_startup(node, config, context):
49
49
  ZoneInfo(node.timezone)
50
50
  except (ValueError, KeyError):
51
51
  raise ContractViolation(node.id, "Invalid schedule timezone") from None
52
- if node.count < 1 or node.max_duration_hours <= 0:
53
- raise ContractViolation(node.id, "Schedule safety limits must be positive")
52
+ # count / max_duration_hours are optional (None = unbounded, run until the
53
+ # workflow is stopped). Only a provided value is bounded (count >= 1,
54
+ # max_duration_hours > 0).
55
+ if (node.count is not None and node.count < 1) or (
56
+ node.max_duration_hours is not None and node.max_duration_hours <= 0
57
+ ):
58
+ raise ContractViolation(node.id, "Schedule safety limits must be positive when set")
54
59
  proxy = _StartupContext(context)
55
60
  try:
56
61
  return await ScheduleNodeExecutor().execute(node.id, node.type, config, proxy)
@@ -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.4.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.4.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