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.
- {programgarden-2.4.1 → programgarden-2.5.1}/PKG-INFO +3 -3
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/executor.py +177 -8
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_semantics.py +30 -5
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/validation_replay.py +1 -1
- {programgarden-2.4.1 → programgarden-2.5.1}/pyproject.toml +3 -3
- {programgarden-2.4.1 → programgarden-2.5.1}/README.md +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/binding_validator.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/client.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/code_worker.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/context.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/deep_fixtures.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/incremental_build.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/node_runner.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_events.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_external.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_orders.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_scenarios.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_sources.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_sqlite.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resolver.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/context.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.4.1 → programgarden-2.5.1}/programgarden/validation_recommender.py +0 -0
- {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.
|
|
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.
|
|
19
|
-
Requires-Dist: programgarden-core (>=2.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
23461
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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.
|
|
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.
|
|
39
|
+
programgarden-core = "^2.5.1"
|
|
40
40
|
programgarden-finance = "^2.0.1"
|
|
41
|
-
programgarden-community = "^2.
|
|
41
|
+
programgarden-community = "^2.3.1"
|
|
42
42
|
|
|
43
43
|
[tool.poetry.group.dev.dependencies]
|
|
44
44
|
pytest = "^8.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
{programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/position_reconciliation.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden-2.4.1 → programgarden-2.5.1}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|