programgarden 2.4.1__tar.gz → 2.5.1__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.1 → programgarden-2.5.1}/PKG-INFO +3 -3
  2. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/executor.py +177 -8
  3. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_semantics.py +30 -5
  4. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/validation_replay.py +1 -1
  5. {programgarden-2.4.1 → programgarden-2.5.1}/pyproject.toml +3 -3
  6. {programgarden-2.4.1 → programgarden-2.5.1}/README.md +0 -0
  7. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/__init__.py +0 -0
  8. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/binding_validator.py +0 -0
  9. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/client.py +0 -0
  10. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/code_worker.py +0 -0
  11. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/context.py +0 -0
  12. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/__init__.py +0 -0
  13. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/adjustment_delivery.py +0 -0
  14. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_evidence.py +0 -0
  15. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_order_totals.py +0 -0
  16. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_snapshot.py +0 -0
  17. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/checkpoint_manager.py +0 -0
  18. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/db_naming.py +0 -0
  19. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/execution_storage.py +0 -0
  20. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/fill_reconciler.py +0 -0
  21. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/order_cancellation.py +0 -0
  22. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/order_recovery.py +0 -0
  23. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/owned_order_cancellation.py +0 -0
  24. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/position_reconciliation.py +0 -0
  25. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/query_builder.py +0 -0
  26. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/workflow_position_tracker.py +0 -0
  27. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/workflow_risk_tracker.py +0 -0
  28. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/deep_fixtures.py +0 -0
  29. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_orderable.py +0 -0
  30. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_pnl.py +0 -0
  31. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_read_evidence.py +0 -0
  32. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/incremental_build.py +0 -0
  33. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/managed_order_control.py +0 -0
  34. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/node_runner.py +0 -0
  35. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/order_lifecycle.py +0 -0
  36. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/plugin/__init__.py +0 -0
  37. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/plugin/sandbox.py +0 -0
  38. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/__init__.py +0 -0
  39. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/llm_errors.py +0 -0
  40. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/llm_provider.py +0 -0
  41. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/reconnect_handler.py +0 -0
  42. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_contracts.py +0 -0
  43. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_events.py +0 -0
  44. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_external.py +0 -0
  45. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_order_adapter.py +0 -0
  46. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_orders.py +0 -0
  47. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_scenarios.py +0 -0
  48. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_sources.py +0 -0
  49. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_sqlite.py +0 -0
  50. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_triggers.py +0 -0
  51. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resolver.py +0 -0
  52. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/__init__.py +0 -0
  53. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/context.py +0 -0
  54. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/limiter.py +0 -0
  55. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/monitor.py +0 -0
  56. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/throttle.py +0 -0
  57. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/semantic_rules.py +0 -0
  58. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/__init__.py +0 -0
  59. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/credential_tools.py +0 -0
  60. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/definition_tools.py +0 -0
  61. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/event_tools.py +0 -0
  62. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/job_tools.py +0 -0
  63. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/registry_tools.py +0 -0
  64. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/sqlite_tools.py +0 -0
  65. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/validation_recommender.py +0 -0
  66. {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.4.1
3
+ Version: 2.5.1
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.1,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.5.1,<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)
@@ -2257,7 +2257,15 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2257
2257
  # 2026-09-26). A provided value is enforced as a cap in the loop below.
2258
2258
  count = config.get("count") # None → no cycle cap
2259
2259
  max_duration_hours = config.get("max_duration_hours") # None → no wall-clock cap
2260
-
2260
+ # Schedule jitter (owner 2026-09-28): stagger each fire 0..jitter_seconds past
2261
+ # the cron instant so many same-cron workflows do not stampede a shared egress
2262
+ # IP. Clamp to [0, 300]; a non-numeric value falls back to 0 (no jitter).
2263
+ try:
2264
+ jitter_seconds = int(config.get("jitter_seconds", 0) or 0)
2265
+ except (TypeError, ValueError):
2266
+ jitter_seconds = 0
2267
+ jitter_seconds = max(0, min(jitter_seconds, 300))
2268
+
2261
2269
  if not enabled:
2262
2270
  context.log("info", f"Schedule disabled: {cron_expr}", node_id)
2263
2271
  return {"trigger": False}
@@ -2289,6 +2297,7 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2289
2297
  # 백그라운드 스케줄러 태스크
2290
2298
  async def scheduler_task():
2291
2299
  import time as _time
2300
+ import random as _random
2292
2301
  cnt = 0
2293
2302
  start_mono = _time.monotonic()
2294
2303
  # None = unbounded; only compute a wall-clock cap when a bound was set.
@@ -2349,10 +2358,30 @@ class ScheduleNodeExecutor(NodeExecutorBase):
2349
2358
  sleep_time = min(delay, 1.0)
2350
2359
  await asyncio.sleep(sleep_time)
2351
2360
  delay -= sleep_time
2352
-
2361
+
2353
2362
  if not context.is_running:
2354
2363
  break
2355
-
2364
+
2365
+ # 스케줄 지터 (오너 2026-09-28): 같은 cron 순간을 공유하는 여러
2366
+ # 워크플로우가 공유 egress IP 로 동시에 몰리는 것을 막으려, 실제 실행을
2367
+ # cron 순간 이후 0~jitter 초 랜덤 지연한다. cron 케이던스 앵커는 그대로다.
2368
+ # dry_run 은 위에서 단일 틱 emit 후 break 하므로 여기 도달하지 않는다
2369
+ # (빌드 예산·replay 의미에 영향 없음).
2370
+ if jitter_seconds > 0 and context.is_running:
2371
+ jitter_delay = _random.uniform(0, jitter_seconds)
2372
+ context.log(
2373
+ "debug",
2374
+ f"Schedule jitter: delaying fire by {jitter_delay:.1f}s "
2375
+ f"(0..{jitter_seconds}s)",
2376
+ node_id,
2377
+ )
2378
+ while jitter_delay > 0 and context.is_running:
2379
+ sleep_time = min(jitter_delay, 1.0)
2380
+ await asyncio.sleep(sleep_time)
2381
+ jitter_delay -= sleep_time
2382
+ if not context.is_running:
2383
+ break
2384
+
2356
2385
  # 스케줄 시간 도달 - 이벤트 발생
2357
2386
  cnt += 1
2358
2387
  context.log("info", f"Schedule tick #{cnt}: {cron_expr}", node_id)
@@ -4125,6 +4154,41 @@ class BrokerNodeExecutor(NodeExecutorBase):
4125
4154
  # 시간 분포가 아니다 — LS 측 복구 시간은 우리가 관측한 적 없다.)
4126
4155
  _FILL_SUBSCRIPTION_LOGIN_RETRY_DELAYS = (2.0, 5.0)
4127
4156
 
4157
+ @staticmethod
4158
+ def _on_workflow_loop(context: ExecutionContext, callback):
4159
+ """Route broker thread callbacks to the loop that owns this workflow.
4160
+
4161
+ The SDK dispatches synchronous websocket listeners in a thread pool.
4162
+ Create notification coroutines only after reaching the owning loop, so
4163
+ shutdown cannot leave an unawaited coroutine or mutate task registries
4164
+ from a broker thread.
4165
+ """
4166
+ loop = asyncio.get_running_loop()
4167
+
4168
+ def deliver(value):
4169
+ if not context.is_shutdown:
4170
+ callback(value)
4171
+
4172
+ def dispatch(value):
4173
+ if context.is_shutdown or loop.is_closed():
4174
+ return
4175
+ try:
4176
+ current = asyncio.get_running_loop()
4177
+ except RuntimeError:
4178
+ current = None
4179
+ if current is loop:
4180
+ deliver(value)
4181
+ else:
4182
+ try:
4183
+ loop.call_soon_threadsafe(deliver, value)
4184
+ except RuntimeError:
4185
+ # The owning loop may close between the check and enqueue.
4186
+ # No coroutine has been created on this path.
4187
+ if not loop.is_closed():
4188
+ raise
4189
+
4190
+ return dispatch
4191
+
4128
4192
  def _start_background_task(
4129
4193
  self, context: ExecutionContext, coroutine, *, notification: bool = False,
4130
4194
  ) -> Optional[asyncio.Task]:
@@ -5052,6 +5116,8 @@ class BrokerNodeExecutor(NodeExecutorBase):
5052
5116
  order_date=order_date,
5053
5117
  symbol=symbol,
5054
5118
  exchange='FUTURES',
5119
+ # TC3 supplies its unit; keep absent/blank evidence unknown.
5120
+ currency=getattr(body, 'crncy_cd', None),
5055
5121
  side=side,
5056
5122
  quantity=quantity,
5057
5123
  price=price,
@@ -5577,6 +5643,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5577
5643
  notification=True,
5578
5644
  )
5579
5645
 
5646
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5580
5647
  tracker.on_account_pnl_change(on_pnl_change)
5581
5648
  await tracker.start()
5582
5649
 
@@ -5655,6 +5722,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5655
5722
  notification=True,
5656
5723
  )
5657
5724
 
5725
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5658
5726
  tracker.on_account_pnl_change(on_pnl_change)
5659
5727
  tracker.on_balance_change(lambda _: on_pnl_change(tracker.get_account_pnl()))
5660
5728
  await tracker.start()
@@ -5703,6 +5771,7 @@ class BrokerNodeExecutor(NodeExecutorBase):
5703
5771
  notification=True,
5704
5772
  )
5705
5773
 
5774
+ on_pnl_change = self._on_workflow_loop(context, on_pnl_change)
5706
5775
  tracker.on_account_pnl_change(on_pnl_change)
5707
5776
  await tracker.start()
5708
5777
 
@@ -21983,7 +22052,11 @@ class WorkflowJob:
21983
22052
  "OverseasStockAccountNode", "OverseasFuturesAccountNode", # 계좌 노드
21984
22053
  "OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
21985
22054
  "KoreaStockAccountNode", "KoreaStockRealAccountNode",
21986
- "SQLiteNode", "HTTPRequestNode", "SessionGateNode", # 데이터 노드
22055
+ # HTTPRequestNode 는 여기서 뺐다(오너 결정 2026-09-28) — config 가
22056
+ # {{ item… }}/{{ index }}/{{ total }} 을 참조하면 상류 목록의 종목마다 1회씩
22057
+ # 반복하고, 참조가 없으면(조건→webhook POST 예제 등) 1회만 실행한다. 분기는
22058
+ # `_should_auto_iterate` 의 HTTPRequestNode 전용 가드가 담당한다.
22059
+ "SQLiteNode", "SessionGateNode", # 데이터 노드
21987
22060
  "CodeNode", # custom code: receives the whole array in `data`, loops in-code (one subprocess call)
21988
22061
  "BacktestEngineNode", "BenchmarkCompareNode", # 분석 노드
21989
22062
  }
@@ -22306,6 +22379,15 @@ class WorkflowJob:
22306
22379
  if config and self._consumes_whole_array(node_type, config) is True:
22307
22380
  return False, "", []
22308
22381
 
22382
+ # HTTPRequestNode: 상류에 배열이 붙어도 config 가 {{ item… }}/{{ index }}/{{ total }}
22383
+ # 을 참조할 때만 종목별로 반복한다(오너 결정 2026-09-28). 참조가 없으면 오늘처럼
22384
+ # 1회만 실행 — 하위 호환(조건 → webhook POST 알림 예제는 N 번 발화하면 안 된다).
22385
+ # 이 시점의 config 는 최상위 {{ item… }} 키가 미평가로 남아 있어(iteration_item 이
22386
+ # 아직 없어 `_resolve_config_expressions` 가 deferred 처리) 판정에 쓸 수 있다.
22387
+ # config 가 None(레거시 호출)이면 `_references_iteration_item` 이 False → 반복 안 함.
22388
+ if node_type == "HTTPRequestNode" and not self._references_iteration_item(config):
22389
+ return False, "", []
22390
+
22309
22391
  # 배열 입력이면 자동 반복 실행
22310
22392
  return True, "item", input_data
22311
22393
 
@@ -22379,7 +22461,8 @@ class WorkflowJob:
22379
22461
  # _rate_limit ClassVar가 있는 노드(주문, HTTP 등)에 한해 min_interval_sec
22380
22462
  # 만큼 간격을 보장한다. skip이 아니라 sleep → 모든 N 아이템이 실행됨.
22381
22463
  # rate-limit이 없는 순수 데이터/계산 노드는 영향 없음 (하위 호환).
22382
- await self._auto_iterate_pacing_sleep(node_id, node.node_type)
22464
+ # config 를 넘겨 사용자 rate_limit_interval(무료 API 분당 제한 등)을 반영.
22465
+ await self._auto_iterate_pacing_sleep(node_id, node.node_type, config)
22383
22466
 
22384
22467
  try:
22385
22468
  outputs = await self.executor.execute_node(
@@ -22417,6 +22500,12 @@ class WorkflowJob:
22417
22500
 
22418
22501
  # 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
22419
22502
  merged = self._merge_iterate_results(all_results)
22503
+ # HTTPRequestNode 전용 후처리 — 일반 병합은 키를 `results[0]` 에서만 뽑아
22504
+ # 첫 항목이 오류(`{"error":…, "item":…}`)면 나머지 항목의 response/status_code/
22505
+ # success 가 통째로 유실된다. HTTP 는 항목 전체를 훑어 `results` 배열을 만들고
22506
+ # 최상위 error/success/키를 항목 기준으로 재계산한다.
22507
+ if node.node_type == "HTTPRequestNode":
22508
+ merged = self._merge_http_iterate_results(all_results, items)
22420
22509
  _safe_print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
22421
22510
 
22422
22511
  return merged
@@ -22552,6 +22641,70 @@ class WorkflowJob:
22552
22641
 
22553
22642
  return merged
22554
22643
 
22644
+ def _merge_http_iterate_results(self, all_results: list, items: list) -> Dict[str, Any]:
22645
+ """HTTPRequestNode auto-iterate 결과 전용 병합.
22646
+
22647
+ 일반 병합(`_merge_iterate_results`)은 array_fields 가 아닌 포트를 마지막 값으로
22648
+ 접고 병합 키를 `results[0].keys()` 에서만 뽑는다 — HTTP 는 첫 항목이 오류
22649
+ (`{"error":…, "item":…}`)면 나머지 항목의 response/status_code/success 포트가
22650
+ 통째로 사라진다. 여기서는 항목 전체를 순회해:
22651
+ - `results`: 항목당 {item, response, status_code, success, error} (순서 보존).
22652
+ - `response`/`status_code`: **마지막 항목** 값(기존 {{ nodes.x.response }} 호환,
22653
+ 첫 포트 유지).
22654
+ - `success`: 전 항목 AND.
22655
+ - `error`: **전 항목 실패**일 때만 대표 사유(재생은 bool(output["error"]) 로
22656
+ 실패를 판정하므로, 한 건이라도 성공하면 "" 로 비워 실패로 안 읽히게 한다).
22657
+ - 최상위 키: 항목 전체의 합집합(첫 항목 오류로 나머지 포트 유실 방지).
22658
+
22659
+ per-item 결과 dict 모양(`programgarden_core.nodes.data.HTTPRequestNode.execute`):
22660
+ 성공 2xx/3xx → {"response","status_code","success":True,"error":None}
22661
+ 4xx → {"response","status_code","success":False,"error":"HTTP 4xx"}
22662
+ 예외(5xx/네트워크/타임아웃, 재시도 소진) → 반복 루프가 {"error":str(e),"item":…}.
22663
+ """
22664
+ entries: list = []
22665
+ for idx, r in enumerate(all_results):
22666
+ rec = r if isinstance(r, dict) else {}
22667
+ item = items[idx] if idx < len(items) else rec.get("item")
22668
+ err = rec.get("error")
22669
+ success = rec.get("success")
22670
+ if success is None:
22671
+ # 명시 success 가 없으면 error 유무로 판정(예외 항목은 error 만 있다).
22672
+ success = not err
22673
+ entries.append({
22674
+ "item": item,
22675
+ "response": rec.get("response"),
22676
+ "status_code": rec.get("status_code"),
22677
+ "success": bool(success),
22678
+ "error": err,
22679
+ })
22680
+
22681
+ out: Dict[str, Any] = {}
22682
+ # 최상위 키 = 항목 전체 합집합(마지막 non-None 우선). `item` 은 예외 항목이 끼워
22683
+ # 넣는 반복 문맥일 뿐 HTTP 출력 포트가 아니므로 최상위에서 제외한다.
22684
+ union_keys: Set[str] = set()
22685
+ for r in all_results:
22686
+ if isinstance(r, dict):
22687
+ union_keys.update(r.keys())
22688
+ union_keys.discard("item")
22689
+ for key in union_keys:
22690
+ for r in reversed(all_results):
22691
+ if isinstance(r, dict) and r.get(key) is not None:
22692
+ out[key] = r[key]
22693
+ break
22694
+
22695
+ out["results"] = entries
22696
+ out["response"] = entries[-1]["response"] if entries else None
22697
+ out["status_code"] = entries[-1]["status_code"] if entries else None
22698
+ out["success"] = all(e["success"] for e in entries) if entries else False
22699
+ all_failed = bool(entries) and all(not e["success"] for e in entries)
22700
+ if all_failed:
22701
+ out["error"] = next((e["error"] for e in entries if e["error"]),
22702
+ "All HTTP requests failed")
22703
+ else:
22704
+ # 한 건이라도 성공 → 최상위 error 는 비운다(재생/하류에서 실패로 안 읽히게).
22705
+ out["error"] = ""
22706
+ return out
22707
+
22555
22708
  def _dependent_descendants(self, node_id: str) -> Set[str]:
22556
22709
  """A waiting data dependency blocks its chain, including dependent joins.
22557
22710
 
@@ -23454,11 +23607,16 @@ class WorkflowJob:
23454
23607
  # A-3: auto-iterate per-item pacing (spacing, NOT skipping)
23455
23608
  # ============================================================
23456
23609
 
23457
- async def _auto_iterate_pacing_sleep(self, node_id: str, node_type: str) -> None:
23610
+ async def _auto_iterate_pacing_sleep(
23611
+ self, node_id: str, node_type: str, config: Optional[Dict[str, Any]] = None,
23612
+ ) -> None:
23458
23613
  """auto-iterate 루프에서 per-item 간격을 보장하는 sleep.
23459
23614
 
23460
- 노드의 _rate_limit.min_interval_sec를 읽어 직전 아이템 실행으로부터
23461
- 충분한 시간이 지나지 않았으면 잔여 시간만큼 sleep 후 실행.
23615
+ 간격은 사용자 config 의 `rate_limit_interval`(없으면 `cooldown_sec`)을 우선하고,
23616
+ 미지정이면 노드의 `_rate_limit.min_interval_sec`(HTTP=1초 기본) 을 쓴다 —
23617
+ `_apply_rate_limit_guard` 의 우선순위와 동일. 이로써 무료 API 의 분당 제한을
23618
+ 사용자가 종목별 간격으로 조절할 수 있다(예: FMP 무료 티어).
23619
+ 직전 아이템 실행으로부터 충분한 시간이 지나지 않았으면 잔여 시간만큼 sleep 후 실행.
23462
23620
  모든 N 아이템이 반드시 실행된다 (skip 없음).
23463
23621
 
23464
23622
  rate-limit이 없는 노드(ConditionNode 등)는 즉시 통과 — 하위 호환.
@@ -23497,7 +23655,18 @@ class WorkflowJob:
23497
23655
  if not class_rate_limit:
23498
23656
  return
23499
23657
 
23658
+ # 사용자 config 오버라이드 (rate_limit_interval 명시이면 우선, 없으면 cooldown_sec)
23659
+ # — `_apply_rate_limit_guard` 와 같은 규칙. 미지정이면 클래스 기본값 유지(HTTP=1초).
23500
23660
  min_interval_sec = class_rate_limit.min_interval_sec
23661
+ if config:
23662
+ user_interval = config.get("rate_limit_interval")
23663
+ if user_interval is None:
23664
+ user_interval = config.get("cooldown_sec")
23665
+ if user_interval is not None:
23666
+ try:
23667
+ min_interval_sec = float(user_interval)
23668
+ except (TypeError, ValueError):
23669
+ pass
23501
23670
  if min_interval_sec <= 0:
23502
23671
  return
23503
23672
 
@@ -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 []
@@ -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.1"
8
+ version = "2.5.1"
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.1"
40
40
  programgarden-finance = "^2.0.1"
41
- programgarden-community = "^2.2.0"
41
+ programgarden-community = "^2.3.1"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]
44
44
  pytest = "^8.0.0"
File without changes