programgarden 2.4.0__tar.gz → 2.5.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.4.0 → programgarden-2.5.0}/PKG-INFO +3 -3
  2. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/executor.py +145 -5
  3. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_semantics.py +30 -5
  4. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resolver.py +13 -2
  5. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/validation_replay.py +1 -1
  6. {programgarden-2.4.0 → programgarden-2.5.0}/pyproject.toml +3 -3
  7. {programgarden-2.4.0 → programgarden-2.5.0}/README.md +0 -0
  8. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/__init__.py +0 -0
  9. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/binding_validator.py +0 -0
  10. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/client.py +0 -0
  11. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/code_worker.py +0 -0
  12. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/context.py +0 -0
  13. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/__init__.py +0 -0
  14. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/adjustment_delivery.py +0 -0
  15. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/broker_evidence.py +0 -0
  16. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/broker_order_totals.py +0 -0
  17. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/broker_snapshot.py +0 -0
  18. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/checkpoint_manager.py +0 -0
  19. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/db_naming.py +0 -0
  20. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/execution_storage.py +0 -0
  21. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/fill_reconciler.py +0 -0
  22. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/order_cancellation.py +0 -0
  23. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/order_recovery.py +0 -0
  24. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/owned_order_cancellation.py +0 -0
  25. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/position_reconciliation.py +0 -0
  26. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/query_builder.py +0 -0
  27. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/workflow_position_tracker.py +0 -0
  28. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  29. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/deep_fixtures.py +0 -0
  30. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/futures_orderable.py +0 -0
  31. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/futures_pnl.py +0 -0
  32. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/futures_read_evidence.py +0 -0
  33. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/incremental_build.py +0 -0
  34. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/managed_order_control.py +0 -0
  35. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/node_runner.py +0 -0
  36. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/order_lifecycle.py +0 -0
  37. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/plugin/__init__.py +0 -0
  38. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/plugin/sandbox.py +0 -0
  39. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/providers/__init__.py +0 -0
  40. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/providers/llm_errors.py +0 -0
  41. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/providers/llm_provider.py +0 -0
  42. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/reconnect_handler.py +0 -0
  43. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_contracts.py +0 -0
  44. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_events.py +0 -0
  45. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_external.py +0 -0
  46. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_order_adapter.py +0 -0
  47. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_orders.py +0 -0
  48. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_scenarios.py +0 -0
  49. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_sources.py +0 -0
  50. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_sqlite.py +0 -0
  51. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/replay_triggers.py +0 -0
  52. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resource/__init__.py +0 -0
  53. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resource/context.py +0 -0
  54. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resource/limiter.py +0 -0
  55. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resource/monitor.py +0 -0
  56. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/resource/throttle.py +0 -0
  57. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/semantic_rules.py +0 -0
  58. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/__init__.py +0 -0
  59. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/credential_tools.py +0 -0
  60. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/definition_tools.py +0 -0
  61. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/event_tools.py +0 -0
  62. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/job_tools.py +0 -0
  63. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/registry_tools.py +0 -0
  64. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/tools/sqlite_tools.py +0 -0
  65. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/validation_recommender.py +0 -0
  66. {programgarden-2.4.0 → programgarden-2.5.0}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.4.0
3
+ Version: 2.5.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.2.0,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.4.0,<3.0.0)
18
+ Requires-Dist: programgarden-community (>=2.3.0,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.5.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)
@@ -4125,6 +4125,41 @@ class BrokerNodeExecutor(NodeExecutorBase):
4125
4125
  # 시간 분포가 아니다 — LS 측 복구 시간은 우리가 관측한 적 없다.)
4126
4126
  _FILL_SUBSCRIPTION_LOGIN_RETRY_DELAYS = (2.0, 5.0)
4127
4127
 
4128
+ @staticmethod
4129
+ def _on_workflow_loop(context: ExecutionContext, callback):
4130
+ """Route broker thread callbacks to the loop that owns this workflow.
4131
+
4132
+ The SDK dispatches synchronous websocket listeners in a thread pool.
4133
+ Create notification coroutines only after reaching the owning loop, so
4134
+ shutdown cannot leave an unawaited coroutine or mutate task registries
4135
+ from a broker thread.
4136
+ """
4137
+ loop = asyncio.get_running_loop()
4138
+
4139
+ def deliver(value):
4140
+ if not context.is_shutdown:
4141
+ callback(value)
4142
+
4143
+ def dispatch(value):
4144
+ if context.is_shutdown or loop.is_closed():
4145
+ return
4146
+ try:
4147
+ current = asyncio.get_running_loop()
4148
+ except RuntimeError:
4149
+ current = None
4150
+ if current is loop:
4151
+ deliver(value)
4152
+ else:
4153
+ try:
4154
+ loop.call_soon_threadsafe(deliver, value)
4155
+ except RuntimeError:
4156
+ # The owning loop may close between the check and enqueue.
4157
+ # No coroutine has been created on this path.
4158
+ if not loop.is_closed():
4159
+ raise
4160
+
4161
+ return dispatch
4162
+
4128
4163
  def _start_background_task(
4129
4164
  self, context: ExecutionContext, coroutine, *, notification: bool = False,
4130
4165
  ) -> Optional[asyncio.Task]:
@@ -5052,6 +5087,8 @@ class BrokerNodeExecutor(NodeExecutorBase):
5052
5087
  order_date=order_date,
5053
5088
  symbol=symbol,
5054
5089
  exchange='FUTURES',
5090
+ # TC3 supplies its unit; keep absent/blank evidence unknown.
5091
+ currency=getattr(body, 'crncy_cd', None),
5055
5092
  side=side,
5056
5093
  quantity=quantity,
5057
5094
  price=price,
@@ -5577,6 +5614,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5577
5614
  notification=True,
5578
5615
  )
5579
5616
 
5617
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5580
5618
  tracker.on_account_pnl_change(on_pnl_change)
5581
5619
  await tracker.start()
5582
5620
 
@@ -5655,6 +5693,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5655
5693
  notification=True,
5656
5694
  )
5657
5695
 
5696
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5658
5697
  tracker.on_account_pnl_change(on_pnl_change)
5659
5698
  tracker.on_balance_change(lambda _: on_pnl_change(tracker.get_account_pnl()))
5660
5699
  await tracker.start()
@@ -5703,6 +5742,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5703
5742
  notification=True,
5704
5743
  )
5705
5744
 
5745
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5706
5746
  tracker.on_account_pnl_change(on_pnl_change)
5707
5747
  await tracker.start()
5708
5748
 
@@ -21983,7 +22023,11 @@ class WorkflowJob:
21983
22023
  "OverseasStockAccountNode", "OverseasFuturesAccountNode", # 계좌 노드
21984
22024
  "OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
21985
22025
  "KoreaStockAccountNode", "KoreaStockRealAccountNode",
21986
- "SQLiteNode", "HTTPRequestNode", "SessionGateNode", # 데이터 노드
22026
+ # HTTPRequestNode 는 여기서 뺐다(오너 결정 2026-09-28) — config 가
22027
+ # {{ item… }}/{{ index }}/{{ total }} 을 참조하면 상류 목록의 종목마다 1회씩
22028
+ # 반복하고, 참조가 없으면(조건→webhook POST 예제 등) 1회만 실행한다. 분기는
22029
+ # `_should_auto_iterate` 의 HTTPRequestNode 전용 가드가 담당한다.
22030
+ "SQLiteNode", "SessionGateNode", # 데이터 노드
21987
22031
  "CodeNode", # custom code: receives the whole array in `data`, loops in-code (one subprocess call)
21988
22032
  "BacktestEngineNode", "BenchmarkCompareNode", # 분석 노드
21989
22033
  }
@@ -22306,6 +22350,15 @@ class WorkflowJob:
22306
22350
  if config and self._consumes_whole_array(node_type, config) is True:
22307
22351
  return False, "", []
22308
22352
 
22353
+ # HTTPRequestNode: 상류에 배열이 붙어도 config 가 {{ item… }}/{{ index }}/{{ total }}
22354
+ # 을 참조할 때만 종목별로 반복한다(오너 결정 2026-09-28). 참조가 없으면 오늘처럼
22355
+ # 1회만 실행 — 하위 호환(조건 → webhook POST 알림 예제는 N 번 발화하면 안 된다).
22356
+ # 이 시점의 config 는 최상위 {{ item… }} 키가 미평가로 남아 있어(iteration_item 이
22357
+ # 아직 없어 `_resolve_config_expressions` 가 deferred 처리) 판정에 쓸 수 있다.
22358
+ # config 가 None(레거시 호출)이면 `_references_iteration_item` 이 False → 반복 안 함.
22359
+ if node_type == "HTTPRequestNode" and not self._references_iteration_item(config):
22360
+ return False, "", []
22361
+
22309
22362
  # 배열 입력이면 자동 반복 실행
22310
22363
  return True, "item", input_data
22311
22364
 
@@ -22379,7 +22432,8 @@ class WorkflowJob:
22379
22432
  # _rate_limit ClassVar가 있는 노드(주문, HTTP 등)에 한해 min_interval_sec
22380
22433
  # 만큼 간격을 보장한다. skip이 아니라 sleep → 모든 N 아이템이 실행됨.
22381
22434
  # rate-limit이 없는 순수 데이터/계산 노드는 영향 없음 (하위 호환).
22382
- await self._auto_iterate_pacing_sleep(node_id, node.node_type)
22435
+ # config 를 넘겨 사용자 rate_limit_interval(무료 API 분당 제한 등)을 반영.
22436
+ await self._auto_iterate_pacing_sleep(node_id, node.node_type, config)
22383
22437
 
22384
22438
  try:
22385
22439
  outputs = await self.executor.execute_node(
@@ -22417,6 +22471,12 @@ class WorkflowJob:
22417
22471
 
22418
22472
  # 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
22419
22473
  merged = self._merge_iterate_results(all_results)
22474
+ # HTTPRequestNode 전용 후처리 — 일반 병합은 키를 `results[0]` 에서만 뽑아
22475
+ # 첫 항목이 오류(`{"error":…, "item":…}`)면 나머지 항목의 response/status_code/
22476
+ # success 가 통째로 유실된다. HTTP 는 항목 전체를 훑어 `results` 배열을 만들고
22477
+ # 최상위 error/success/키를 항목 기준으로 재계산한다.
22478
+ if node.node_type == "HTTPRequestNode":
22479
+ merged = self._merge_http_iterate_results(all_results, items)
22420
22480
  _safe_print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
22421
22481
 
22422
22482
  return merged
@@ -22552,6 +22612,70 @@ class WorkflowJob:
22552
22612
 
22553
22613
  return merged
22554
22614
 
22615
+ def _merge_http_iterate_results(self, all_results: list, items: list) -> Dict[str, Any]:
22616
+ """HTTPRequestNode auto-iterate 결과 전용 병합.
22617
+
22618
+ 일반 병합(`_merge_iterate_results`)은 array_fields 가 아닌 포트를 마지막 값으로
22619
+ 접고 병합 키를 `results[0].keys()` 에서만 뽑는다 — HTTP 는 첫 항목이 오류
22620
+ (`{"error":…, "item":…}`)면 나머지 항목의 response/status_code/success 포트가
22621
+ 통째로 사라진다. 여기서는 항목 전체를 순회해:
22622
+ - `results`: 항목당 {item, response, status_code, success, error} (순서 보존).
22623
+ - `response`/`status_code`: **마지막 항목** 값(기존 {{ nodes.x.response }} 호환,
22624
+ 첫 포트 유지).
22625
+ - `success`: 전 항목 AND.
22626
+ - `error`: **전 항목 실패**일 때만 대표 사유(재생은 bool(output["error"]) 로
22627
+ 실패를 판정하므로, 한 건이라도 성공하면 "" 로 비워 실패로 안 읽히게 한다).
22628
+ - 최상위 키: 항목 전체의 합집합(첫 항목 오류로 나머지 포트 유실 방지).
22629
+
22630
+ per-item 결과 dict 모양(`programgarden_core.nodes.data.HTTPRequestNode.execute`):
22631
+ 성공 2xx/3xx → {"response","status_code","success":True,"error":None}
22632
+ 4xx → {"response","status_code","success":False,"error":"HTTP 4xx"}
22633
+ 예외(5xx/네트워크/타임아웃, 재시도 소진) → 반복 루프가 {"error":str(e),"item":…}.
22634
+ """
22635
+ entries: list = []
22636
+ for idx, r in enumerate(all_results):
22637
+ rec = r if isinstance(r, dict) else {}
22638
+ item = items[idx] if idx < len(items) else rec.get("item")
22639
+ err = rec.get("error")
22640
+ success = rec.get("success")
22641
+ if success is None:
22642
+ # 명시 success 가 없으면 error 유무로 판정(예외 항목은 error 만 있다).
22643
+ success = not err
22644
+ entries.append({
22645
+ "item": item,
22646
+ "response": rec.get("response"),
22647
+ "status_code": rec.get("status_code"),
22648
+ "success": bool(success),
22649
+ "error": err,
22650
+ })
22651
+
22652
+ out: Dict[str, Any] = {}
22653
+ # 최상위 키 = 항목 전체 합집합(마지막 non-None 우선). `item` 은 예외 항목이 끼워
22654
+ # 넣는 반복 문맥일 뿐 HTTP 출력 포트가 아니므로 최상위에서 제외한다.
22655
+ union_keys: Set[str] = set()
22656
+ for r in all_results:
22657
+ if isinstance(r, dict):
22658
+ union_keys.update(r.keys())
22659
+ union_keys.discard("item")
22660
+ for key in union_keys:
22661
+ for r in reversed(all_results):
22662
+ if isinstance(r, dict) and r.get(key) is not None:
22663
+ out[key] = r[key]
22664
+ break
22665
+
22666
+ out["results"] = entries
22667
+ out["response"] = entries[-1]["response"] if entries else None
22668
+ out["status_code"] = entries[-1]["status_code"] if entries else None
22669
+ out["success"] = all(e["success"] for e in entries) if entries else False
22670
+ all_failed = bool(entries) and all(not e["success"] for e in entries)
22671
+ if all_failed:
22672
+ out["error"] = next((e["error"] for e in entries if e["error"]),
22673
+ "All HTTP requests failed")
22674
+ else:
22675
+ # 한 건이라도 성공 → 최상위 error 는 비운다(재생/하류에서 실패로 안 읽히게).
22676
+ out["error"] = ""
22677
+ return out
22678
+
22555
22679
  def _dependent_descendants(self, node_id: str) -> Set[str]:
22556
22680
  """A waiting data dependency blocks its chain, including dependent joins.
22557
22681
 
@@ -23454,11 +23578,16 @@ class WorkflowJob:
23454
23578
  # A-3: auto-iterate per-item pacing (spacing, NOT skipping)
23455
23579
  # ============================================================
23456
23580
 
23457
- async def _auto_iterate_pacing_sleep(self, node_id: str, node_type: str) -> None:
23581
+ async def _auto_iterate_pacing_sleep(
23582
+ self, node_id: str, node_type: str, config: Optional[Dict[str, Any]] = None,
23583
+ ) -> None:
23458
23584
  """auto-iterate 루프에서 per-item 간격을 보장하는 sleep.
23459
23585
 
23460
- 노드의 _rate_limit.min_interval_sec를 읽어 직전 아이템 실행으로부터
23461
- 충분한 시간이 지나지 않았으면 잔여 시간만큼 sleep 후 실행.
23586
+ 간격은 사용자 config 의 `rate_limit_interval`(없으면 `cooldown_sec`)을 우선하고,
23587
+ 미지정이면 노드의 `_rate_limit.min_interval_sec`(HTTP=1초 기본) 을 쓴다 —
23588
+ `_apply_rate_limit_guard` 의 우선순위와 동일. 이로써 무료 API 의 분당 제한을
23589
+ 사용자가 종목별 간격으로 조절할 수 있다(예: FMP 무료 티어).
23590
+ 직전 아이템 실행으로부터 충분한 시간이 지나지 않았으면 잔여 시간만큼 sleep 후 실행.
23462
23591
  모든 N 아이템이 반드시 실행된다 (skip 없음).
23463
23592
 
23464
23593
  rate-limit이 없는 노드(ConditionNode 등)는 즉시 통과 — 하위 호환.
@@ -23497,7 +23626,18 @@ class WorkflowJob:
23497
23626
  if not class_rate_limit:
23498
23627
  return
23499
23628
 
23629
+ # 사용자 config 오버라이드 (rate_limit_interval 명시이면 우선, 없으면 cooldown_sec)
23630
+ # — `_apply_rate_limit_guard` 와 같은 규칙. 미지정이면 클래스 기본값 유지(HTTP=1초).
23500
23631
  min_interval_sec = class_rate_limit.min_interval_sec
23632
+ if config:
23633
+ user_interval = config.get("rate_limit_interval")
23634
+ if user_interval is None:
23635
+ user_interval = config.get("cooldown_sec")
23636
+ if user_interval is not None:
23637
+ try:
23638
+ min_interval_sec = float(user_interval)
23639
+ except (TypeError, ValueError):
23640
+ pass
23501
23641
  if min_interval_sec <= 0:
23502
23642
  return
23503
23643
 
@@ -278,6 +278,24 @@ def _iterates(node_type: str, role: str, c, own_fields: List[str]) -> Optional[D
278
278
  return None
279
279
  if node_type in c["no_auto_iterate"] or role == "broker":
280
280
  return None
281
+ if node_type == "HTTPRequestNode":
282
+ # HTTPRequestNode fans out per item ONLY when its config references an
283
+ # iteration binding ({{ item... }} / {{ index }} / {{ total }}); a config
284
+ # without such a reference runs ONCE (executor _should_auto_iterate,
285
+ # owner decision 2026-09-28 -- the condition->webhook POST example must
286
+ # not fire N times). It has no fixed `symbols` field, so `item` follows
287
+ # the upstream element shape and the per-item outcomes are exposed on the
288
+ # `results` output port; the recording key is EXCHANGE:SYMBOL for
289
+ # {symbol, exchange} items and a string/other item falls back to the
290
+ # index key (replay_external.py).
291
+ return {
292
+ "mode": "per_item",
293
+ "item": "upstream_element",
294
+ "when": "config_references_item_binding",
295
+ "record_key": "EXCHANGE:SYMBOL",
296
+ "single": "no_item_binding",
297
+ "results_port": "results",
298
+ }
281
299
  if "symbols" not in own_fields:
282
300
  return None
283
301
  block: Dict[str, Any] = {
@@ -523,11 +541,18 @@ def execution_semantics_text(node_type: str) -> List[str]:
523
541
 
524
542
  it = block.get("iteration")
525
543
  if isinstance(it, dict):
526
- line = ("Auto-iterates per item over an upstream symbol array; each per-item "
527
- "recording is keyed EXCHANGE:SYMBOL and a single non-iterated "
528
- "recording sets item=null.")
529
- if it.get("default_exchange"):
530
- line = line[:-1] + " (exchange defaults to KRX when omitted)."
544
+ if it.get("when") == "config_references_item_binding":
545
+ line = ("Auto-iterates per item over the upstream list ONLY when the config "
546
+ "references an item binding ({{ item }} / {{ index }} / {{ total }}); "
547
+ "otherwise it runs once. Each per-item recording is keyed EXCHANGE:SYMBOL "
548
+ "(a string or other item falls back to the index key), and per-item "
549
+ "outcomes are exposed on the 'results' output port.")
550
+ else:
551
+ line = ("Auto-iterates per item over an upstream symbol array; each per-item "
552
+ "recording is keyed EXCHANGE:SYMBOL and a single non-iterated "
553
+ "recording sets item=null.")
554
+ if it.get("default_exchange"):
555
+ line = line[:-1] + " (exchange defaults to KRX when omitted)."
531
556
  lines.append(line)
532
557
 
533
558
  emits = block.get("emits_events") or []
@@ -8,6 +8,14 @@ deterministic self-correction without parsing free-form strings.
8
8
 
9
9
  from typing import Optional, List, Dict, Any, Set, Tuple
10
10
 
11
+ # product_scope 값 → 그 스코프의 브로커 노드 타입. MISSING_REQUIRED_BROKER 안내에 쓴다.
12
+ # (ProductScope.ALL 은 브로커가 필요 없으므로 없다.)
13
+ _BROKER_NODE_BY_SCOPE: Dict[str, str] = {
14
+ "overseas_stock": "OverseasStockBrokerNode",
15
+ "overseas_futures": "OverseasFuturesBrokerNode",
16
+ "korea_stock": "KoreaStockBrokerNode",
17
+ }
18
+
11
19
  from programgarden_core import (
12
20
  ErrorCode,
13
21
  ErrorInfo,
@@ -1663,8 +1671,11 @@ class WorkflowResolver:
1663
1671
 
1664
1672
  # product_scope match
1665
1673
  if scope.value not in available_brokers:
1666
- product_label = "overseas_stock" if scope == ProductScope.STOCK else "overseas_futures"
1667
- broker_node = "OverseasStockBrokerNode" if scope == ProductScope.STOCK else "OverseasFuturesBrokerNode"
1674
+ # 스코프별 브로커 노드 매핑. 2.4.0 까지는 STOCK 이 아닌 모든 스코프를 해외선물로
1675
+ # 표기해 국내주식 노드에 "OverseasFuturesBrokerNode 를 추가하라" 고 안내했다
1676
+ # (AI 저작 루프가 그 말을 믿고 헤맴 — 2026-09-27 dev smoke).
1677
+ product_label = scope.value
1678
+ broker_node = _BROKER_NODE_BY_SCOPE.get(scope, "OverseasFuturesBrokerNode")
1668
1679
  result.add(
1669
1680
  build_error(
1670
1681
  ErrorCode.MISSING_REQUIRED_BROKER,
@@ -163,7 +163,7 @@ class ReplayJob(WorkflowJob):
163
163
  # Validation never reads or writes a running strategy's execution DB.
164
164
  return None
165
165
 
166
- async def _auto_iterate_pacing_sleep(self, node_id, node_type):
166
+ async def _auto_iterate_pacing_sleep(self, node_id, node_type, config=None):
167
167
  return None
168
168
 
169
169
  def _rate_limit_now(self):
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.4.0"
8
+ version = "2.5.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.4.0"
39
+ programgarden-core = "^2.5.0"
40
40
  programgarden-finance = "^2.0.1"
41
- programgarden-community = "^2.2.0"
41
+ programgarden-community = "^2.3.0"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]
44
44
  pytest = "^8.0.0"
File without changes