programgarden 1.29.0__tar.gz → 1.29.2__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-1.29.0 → programgarden-1.29.2}/PKG-INFO +1 -1
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/executor.py +261 -36
- {programgarden-1.29.0 → programgarden-1.29.2}/pyproject.toml +1 -1
- {programgarden-1.29.0 → programgarden-1.29.2}/README.md +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/binding_validator.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/client.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/code_worker.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/context.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/node_runner.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resolver.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/context.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/validation_recommender.py +0 -0
|
@@ -54,6 +54,28 @@ _RESERVED_ITERATION_ROOTS: frozenset = frozenset({"item", "row"})
|
|
|
54
54
|
|
|
55
55
|
_INLINE_EXPR_PATTERN = re.compile(r"\{\{\s*(.+?)\s*\}\}")
|
|
56
56
|
|
|
57
|
+
# Realtime (event-source) node types — the ONE list. Three separate lifecycle
|
|
58
|
+
# decisions key off it: (1) whether the job enters the event loop (and so keeps
|
|
59
|
+
# the WebSocket subscriptions alive instead of tearing them down in `finally`),
|
|
60
|
+
# (2) whether the node auto-triggers its downstream chain on each event, and
|
|
61
|
+
# (3) whether it is skipped on re-execution (it is already running).
|
|
62
|
+
#
|
|
63
|
+
# It used to be copy-pasted at all three sites, and the Korea family — added
|
|
64
|
+
# later — was never added to any of them. Result: Korea realtime workflows had
|
|
65
|
+
# no event source, so the job completed immediately, cleanup closed the socket,
|
|
66
|
+
# and ticks never reached the table. Keep this the single source of truth;
|
|
67
|
+
# `test_realtime_node_types_cover_declarations` fails if a node declares
|
|
68
|
+
# `stay_connected` but is missing here.
|
|
69
|
+
REALTIME_NODE_TYPES: frozenset = frozenset({
|
|
70
|
+
"RealAccountNode", "RealMarketDataNode", "RealOrderEventNode",
|
|
71
|
+
"OverseasStockRealAccountNode", "OverseasStockRealMarketDataNode",
|
|
72
|
+
"OverseasStockRealOrderEventNode",
|
|
73
|
+
"OverseasFuturesRealAccountNode", "OverseasFuturesRealMarketDataNode",
|
|
74
|
+
"OverseasFuturesRealOrderEventNode",
|
|
75
|
+
"KoreaStockRealAccountNode", "KoreaStockRealMarketDataNode",
|
|
76
|
+
"KoreaStockRealOrderEventNode",
|
|
77
|
+
})
|
|
78
|
+
|
|
57
79
|
|
|
58
80
|
def _free_root_names(text: str) -> Set[str]:
|
|
59
81
|
"""Return the set of free-variable root identifiers referenced (ctx=Load) by
|
|
@@ -736,8 +758,14 @@ class ThrottleNodeExecutor(NodeExecutorBase):
|
|
|
736
758
|
interval_sec = config.get("interval_sec", 5.0)
|
|
737
759
|
pass_first = config.get("pass_first", True)
|
|
738
760
|
|
|
739
|
-
# State key for this node
|
|
761
|
+
# State key for this node.
|
|
762
|
+
# Split 분기 안에서는 종목마다 별도 스코프를 쓴다. 노드 하나를 분기 아이템들이
|
|
763
|
+
# 공유하므로, 스코프가 없으면 쿨다운까지 공유돼 한 사이클에 한 종목만 통과하고
|
|
764
|
+
# 나머지 종목은 매번 버려진다(표에 종목당 1행이 안 나온다).
|
|
740
765
|
state_key = "_throttle_state"
|
|
766
|
+
branch_scope = config.get("_branch_scope")
|
|
767
|
+
if branch_scope:
|
|
768
|
+
state_key = f"{state_key}:{branch_scope}"
|
|
741
769
|
throttle_state = context.get_node_state(node_id, state_key) or {
|
|
742
770
|
"last_passed_at": None,
|
|
743
771
|
"skipped_count": 0,
|
|
@@ -930,7 +958,18 @@ class ThrottleNodeExecutor(NodeExecutorBase):
|
|
|
930
958
|
for key, value in all_outputs.items():
|
|
931
959
|
if not key.startswith("_"):
|
|
932
960
|
input_data[key] = value
|
|
933
|
-
|
|
961
|
+
|
|
962
|
+
# 상류 출력은 모든 실행 경로(메인 플로우/Split 분기/실시간 재실행)가 이미
|
|
963
|
+
# `_input_<node_id>` 의사 노드에 모아둔다. 위의 `context._workflow_edges` 워크는
|
|
964
|
+
# **프로덕션에서 그 속성을 아무도 설정하지 않아** 통째로 죽어 있었다(테스트만 주입).
|
|
965
|
+
# 그래서 ThrottleNode 는 실시간 핸들러가 `_realtime_data` 를 직접 꽂아주는 경로에서만
|
|
966
|
+
# 동작했고, Split 분기 안에서는 상류에 실시간 시세가 흘러도 영원히 빈 입력을 봤다
|
|
967
|
+
# → 영원히 cooldown → 분기 결과가 전부 버려져 Aggregate 표가 안 찼다.
|
|
968
|
+
upstream = context.get_all_outputs(f"_input_{node_id}") or {}
|
|
969
|
+
for key, value in upstream.items():
|
|
970
|
+
if not key.startswith("_"):
|
|
971
|
+
input_data.setdefault(key, value)
|
|
972
|
+
|
|
934
973
|
return input_data
|
|
935
974
|
|
|
936
975
|
|
|
@@ -989,6 +1028,104 @@ def _public_outputs(outputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
989
1028
|
}
|
|
990
1029
|
|
|
991
1030
|
|
|
1031
|
+
# ── Split 분기 × 실시간 시세: 종목별 분리 (2026-07-14) ────────────────────────
|
|
1032
|
+
# 실시간 시세 노드는 **노드 하나에 구독 종목이 전부 합쳐진** symbol-keyed 출력을 낸다
|
|
1033
|
+
# (`ohlcv_data = {"005930": [bar], "000660": [bar]}` — on_tick 이 종목별 bar 를 한 dict 에
|
|
1034
|
+
# 누적). 그런데 Split 분기의 아이템 하나는 자기 종목만 봐야 한다. 자르지 않으면 모든
|
|
1035
|
+
# 분기가 같은 덩어리를 보고 같은 값을 수집해 "종목당 1행" 표가 나오지 않는다.
|
|
1036
|
+
_SYMBOL_ITEM_KEYS = ("symbol", "shcode", "code")
|
|
1037
|
+
|
|
1038
|
+
# Split 분기가 수집할 값을 고를 때 payload 로 인정하는 포트(선언 순). 예전엔 공개 포트
|
|
1039
|
+
# 중 **아무거나 첫 번째**(dict 순서 의존)를 집어서, 시세 노드가 `symbols` 를 먼저 쓰면
|
|
1040
|
+
# 가격 대신 종목코드 목록이 표에 실렸다(비결정 + 잘못된 데이터).
|
|
1041
|
+
_BRANCH_PAYLOAD_PORTS = ("result", "value", "data", "ohlcv_data")
|
|
1042
|
+
|
|
1043
|
+
# 실시간 노드가 어떤 분기 스코프(종목)까지 구독을 걸었는지 기록하는 node_state 키.
|
|
1044
|
+
# 종목마다 최초 1회만 실행하고, 틱마다의 분기 재구동에선 건너뛴다(재구독 폭주 방지).
|
|
1045
|
+
_SUBSCRIBED_SCOPES_KEY = "_subscribed_branch_scopes"
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _item_symbol(item: Any) -> Optional[str]:
|
|
1049
|
+
"""Split 아이템에서 종목코드를 뽑는다 ('005930' 또는 {'symbol': '005930'})."""
|
|
1050
|
+
if isinstance(item, str):
|
|
1051
|
+
return item or None
|
|
1052
|
+
if isinstance(item, dict):
|
|
1053
|
+
for key in _SYMBOL_ITEM_KEYS:
|
|
1054
|
+
value = item.get(key)
|
|
1055
|
+
if isinstance(value, str) and value:
|
|
1056
|
+
return value
|
|
1057
|
+
return None
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _is_symbol_keyed_payload(value: Any) -> bool:
|
|
1061
|
+
"""`{"005930": [bar], "000660": [bar]}` 처럼 종목코드로 키가 잡힌 시세 payload 인가."""
|
|
1062
|
+
return (
|
|
1063
|
+
isinstance(value, dict)
|
|
1064
|
+
and bool(value)
|
|
1065
|
+
and all(isinstance(v, list) for v in value.values())
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _slice_realtime_outputs(outputs: Dict[str, Any], symbol: str) -> Dict[str, Any]:
|
|
1070
|
+
"""실시간 시세 노드의 합쳐진 출력을 한 종목 몫으로 자른다.
|
|
1071
|
+
|
|
1072
|
+
**이 종목의 틱이 아직 없으면 공개 포트를 하나도 내지 않는다**(내부 `_` 포트만 남긴다).
|
|
1073
|
+
빈 값이나 `symbols` 목록만 내보내면 Throttle 이 "흘릴 데이터가 있다"고 착각해 통과시키고,
|
|
1074
|
+
표에 **가격 없는 행**이 낀다. 실제로 HKEX 라이브에서 아직 체결이 없던 2번째 종목이
|
|
1075
|
+
합쳐진 덩어리를 그대로 행으로 실어 이 결함이 드러났다(2026-07-14).
|
|
1076
|
+
"""
|
|
1077
|
+
meta = {
|
|
1078
|
+
k: v for k, v in outputs.items()
|
|
1079
|
+
if isinstance(k, str) and k.startswith("_")
|
|
1080
|
+
}
|
|
1081
|
+
payload: Dict[str, Any] = {}
|
|
1082
|
+
has_payload = False
|
|
1083
|
+
|
|
1084
|
+
for port, value in outputs.items():
|
|
1085
|
+
if isinstance(port, str) and port.startswith("_"):
|
|
1086
|
+
continue
|
|
1087
|
+
if _is_symbol_keyed_payload(value):
|
|
1088
|
+
if symbol in value:
|
|
1089
|
+
payload[port] = value[symbol]
|
|
1090
|
+
has_payload = True
|
|
1091
|
+
# 이 종목 몫이 없으면 그 포트는 **아예 내지 않는다** (덩어리 통과 금지)
|
|
1092
|
+
continue
|
|
1093
|
+
if port == "symbol":
|
|
1094
|
+
payload[port] = symbol
|
|
1095
|
+
continue
|
|
1096
|
+
if port == "symbols" and isinstance(value, list):
|
|
1097
|
+
payload[port] = [s for s in value if _item_symbol(s) == symbol]
|
|
1098
|
+
continue
|
|
1099
|
+
payload[port] = value
|
|
1100
|
+
|
|
1101
|
+
if not has_payload:
|
|
1102
|
+
return meta
|
|
1103
|
+
return {**meta, **payload}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _realtime_branch_row(public: Dict[str, Any], symbol: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
1107
|
+
"""실시간 분기 아이템이 표에 기여할 **평평한 한 행**을 만든다.
|
|
1108
|
+
|
|
1109
|
+
슬라이스된 payload 는 이 종목의 bar 리스트(`[{date, open, high, low, close, volume}]`)
|
|
1110
|
+
이므로, 최신 bar 에 종목코드를 붙여 `{symbol, date, open, ..., volume}` 한 행으로 낸다.
|
|
1111
|
+
TableDisplayNode 는 평평한 dict 의 리스트를 기대한다(첫 행의 키가 곧 표의 열).
|
|
1112
|
+
"""
|
|
1113
|
+
for port in ("data", "ohlcv_data"):
|
|
1114
|
+
value = public.get(port)
|
|
1115
|
+
bar = None
|
|
1116
|
+
if isinstance(value, list) and value and isinstance(value[-1], dict):
|
|
1117
|
+
bar = value[-1]
|
|
1118
|
+
elif isinstance(value, dict) and value and not isinstance(next(iter(value.values()), None), (dict, list)):
|
|
1119
|
+
bar = value
|
|
1120
|
+
if bar is None:
|
|
1121
|
+
continue
|
|
1122
|
+
row = dict(bar)
|
|
1123
|
+
if symbol:
|
|
1124
|
+
row.setdefault("symbol", symbol)
|
|
1125
|
+
return row
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
|
|
992
1129
|
# ── 해외주식 거래소 표기 정규화 (2026-07-14 결함3) ────────────────────────────
|
|
993
1130
|
# LS 는 거래소를 갈래마다 다른 표현으로 준다: REST balance(COSOQ00201) MktTpNm 은
|
|
994
1131
|
# 한글 표시명('뉴욕'/'나스닥'), FcurrMktCode 는 LS 코드('81'/'82'/'83'), screener/
|
|
@@ -17523,12 +17660,7 @@ class WorkflowJob:
|
|
|
17523
17660
|
result = []
|
|
17524
17661
|
for node_id, node in self.workflow.nodes.items():
|
|
17525
17662
|
# 실시간 노드 (stay_connected 설정)
|
|
17526
|
-
if node.node_type in
|
|
17527
|
-
"RealAccountNode", "RealMarketDataNode", "RealOrderEventNode",
|
|
17528
|
-
"OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
|
|
17529
|
-
"OverseasStockRealMarketDataNode", "OverseasFuturesRealMarketDataNode",
|
|
17530
|
-
"OverseasStockRealOrderEventNode", "OverseasFuturesRealOrderEventNode",
|
|
17531
|
-
):
|
|
17663
|
+
if node.node_type in REALTIME_NODE_TYPES:
|
|
17532
17664
|
if node.config.get("stay_connected", True):
|
|
17533
17665
|
result.append(node_id)
|
|
17534
17666
|
# 영속 노드 (백그라운드 태스크를 등록하는 노드)
|
|
@@ -18609,7 +18741,7 @@ class WorkflowJob:
|
|
|
18609
18741
|
return
|
|
18610
18742
|
|
|
18611
18743
|
total = len(input_array)
|
|
18612
|
-
|
|
18744
|
+
results_by_index: Dict[int, Any] = {}
|
|
18613
18745
|
errors: List[str] = []
|
|
18614
18746
|
|
|
18615
18747
|
print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
|
|
@@ -18618,7 +18750,7 @@ class WorkflowJob:
|
|
|
18618
18750
|
if parallel:
|
|
18619
18751
|
# Parallel execution
|
|
18620
18752
|
async def execute_item(idx: int, item: Any) -> Any:
|
|
18621
|
-
return await self._execute_branch_for_item(
|
|
18753
|
+
return idx, await self._execute_branch_for_item(
|
|
18622
18754
|
split_id=split_id,
|
|
18623
18755
|
branch_order=branch_order,
|
|
18624
18756
|
item=item,
|
|
@@ -18629,32 +18761,28 @@ class WorkflowJob:
|
|
|
18629
18761
|
tasks = [execute_item(i, item) for i, item in enumerate(input_array)]
|
|
18630
18762
|
for coro in asyncio.as_completed(tasks):
|
|
18631
18763
|
try:
|
|
18632
|
-
result = await coro
|
|
18633
|
-
|
|
18634
|
-
collected_results.append(result)
|
|
18764
|
+
idx, result = await coro
|
|
18765
|
+
results_by_index[idx] = result
|
|
18635
18766
|
except Exception as e:
|
|
18636
18767
|
if continue_on_error:
|
|
18637
18768
|
errors.append(str(e))
|
|
18638
|
-
collected_results.append(None)
|
|
18639
18769
|
else:
|
|
18640
18770
|
raise
|
|
18641
18771
|
else:
|
|
18642
18772
|
# Sequential execution
|
|
18643
18773
|
for idx, item in enumerate(input_array):
|
|
18644
18774
|
try:
|
|
18645
|
-
|
|
18775
|
+
results_by_index[idx] = await self._execute_branch_for_item(
|
|
18646
18776
|
split_id=split_id,
|
|
18647
18777
|
branch_order=branch_order,
|
|
18648
18778
|
item=item,
|
|
18649
18779
|
index=idx,
|
|
18650
18780
|
total=total,
|
|
18651
18781
|
)
|
|
18652
|
-
if result is not _SKIP_BRANCH_ITEM:
|
|
18653
|
-
collected_results.append(result)
|
|
18654
18782
|
except Exception as e:
|
|
18655
18783
|
if continue_on_error:
|
|
18656
18784
|
errors.append(str(e))
|
|
18657
|
-
|
|
18785
|
+
results_by_index[idx] = None
|
|
18658
18786
|
else:
|
|
18659
18787
|
raise
|
|
18660
18788
|
|
|
@@ -18662,6 +18790,34 @@ class WorkflowJob:
|
|
|
18662
18790
|
if delay_ms > 0 and idx < total - 1:
|
|
18663
18791
|
await asyncio.sleep(delay_ms / 1000)
|
|
18664
18792
|
|
|
18793
|
+
# === Collect, holding the last known row per item (realtime branches) ===
|
|
18794
|
+
# 실시간 분기는 틱마다 재구동되고 ThrottleNode 는 종목별 쿨다운으로 대부분의
|
|
18795
|
+
# 사이클을 막는다. 통과하지 못한 종목을 그냥 버리면 수집 배열이 사이클마다
|
|
18796
|
+
# 통째로 비어 **표가 깜빡인다**(찼다 → 빈 표 → 찼다). 이번 사이클에 새 값이 없으면
|
|
18797
|
+
# 그 종목의 **직전 행을 유지**한다. 실시간이 아닌 분기는 기존대로 버린다
|
|
18798
|
+
# (아직 실데이터가 없는 아이템을 표에 끼우지 않기 위함 — 2026-07-14 결함2).
|
|
18799
|
+
realtime_branch = any(
|
|
18800
|
+
(self.workflow.nodes.get(n).node_type in REALTIME_NODE_TYPES)
|
|
18801
|
+
for n in branch_order
|
|
18802
|
+
if self.workflow.nodes.get(n)
|
|
18803
|
+
)
|
|
18804
|
+
held_key = "_last_branch_rows"
|
|
18805
|
+
held: Dict[str, Any] = dict(self.context.get_node_state(aggregate_id, held_key) or {})
|
|
18806
|
+
|
|
18807
|
+
collected_results: List[Any] = []
|
|
18808
|
+
for idx in range(total):
|
|
18809
|
+
result = results_by_index.get(idx, _SKIP_BRANCH_ITEM)
|
|
18810
|
+
if result is _SKIP_BRANCH_ITEM:
|
|
18811
|
+
if realtime_branch and str(idx) in held:
|
|
18812
|
+
collected_results.append(held[str(idx)])
|
|
18813
|
+
continue
|
|
18814
|
+
collected_results.append(result)
|
|
18815
|
+
if realtime_branch and result is not None:
|
|
18816
|
+
held[str(idx)] = result
|
|
18817
|
+
|
|
18818
|
+
if realtime_branch:
|
|
18819
|
+
self.context.set_node_state(aggregate_id, held_key, held)
|
|
18820
|
+
|
|
18665
18821
|
# === Store results for AggregateNode ===
|
|
18666
18822
|
self.context.set_node_state(aggregate_id, "_collected_items", collected_results)
|
|
18667
18823
|
|
|
@@ -18716,21 +18872,60 @@ class WorkflowJob:
|
|
|
18716
18872
|
result = item
|
|
18717
18873
|
last_had_public = None # None = branch 노드 없음 / True|False = 마지막 노드 결과
|
|
18718
18874
|
|
|
18875
|
+
# 이 분기가 실시간 시세를 다루는가 + 이 아이템의 종목은 무엇인가.
|
|
18876
|
+
# (실시간 분기에서만 종목별 슬라이싱/행 생성을 적용해 파장을 그 경우로 가둔다.)
|
|
18877
|
+
symbol = _item_symbol(item)
|
|
18878
|
+
realtime_branch = any(
|
|
18879
|
+
(self.workflow.nodes.get(n).node_type in REALTIME_NODE_TYPES)
|
|
18880
|
+
for n in branch_order
|
|
18881
|
+
if self.workflow.nodes.get(n)
|
|
18882
|
+
)
|
|
18883
|
+
# 분기 아이템마다 독립된 상태 스코프. ThrottleNode 의 쿨다운은 노드별 상태라
|
|
18884
|
+
# 종목들이 한 노드를 공유하면 대기시간까지 공유돼 한 번에 한 종목만 통과한다.
|
|
18885
|
+
branch_scope = f"{split_id}#{index}"
|
|
18886
|
+
|
|
18719
18887
|
# Execute each branch node
|
|
18720
18888
|
for node_id in branch_order:
|
|
18721
18889
|
node = self.workflow.nodes.get(node_id)
|
|
18722
18890
|
if not node:
|
|
18723
18891
|
continue
|
|
18724
18892
|
|
|
18893
|
+
is_realtime = node.node_type in REALTIME_NODE_TYPES
|
|
18894
|
+
subscribed: List[str] = []
|
|
18895
|
+
if is_realtime:
|
|
18896
|
+
# 실시간 노드는 **분기 아이템(종목)마다 최초 1회는 반드시 실행**해야 한다
|
|
18897
|
+
# — 그래야 그 종목의 구독이 걸린다. 그 뒤(틱마다의 분기 재구동)엔 실행하지
|
|
18898
|
+
# 않는다: 재실행하면 틱마다 재구독이 걸려 소켓이 폭주한다. 최신 시세는 틱
|
|
18899
|
+
# 콜백이 이 노드의 context 출력에 계속 갱신해 두므로 하류는 그걸 읽으면 된다.
|
|
18900
|
+
#
|
|
18901
|
+
# ⚠️ 예전엔 "출력이 있으면 스킵"으로 판단했는데, 첫 종목이 실행되며 출력을
|
|
18902
|
+
# 남기는 순간 **2번째 이후 종목의 최초 구독까지 건너뛰어** 그 종목엔 시세가
|
|
18903
|
+
# 영영 오지 않았다(HKEX 라이브에서 2번째 종목이 통째로 死, 2026-07-14).
|
|
18904
|
+
subscribed = list(
|
|
18905
|
+
self.context.get_node_state(node_id, _SUBSCRIBED_SCOPES_KEY) or []
|
|
18906
|
+
)
|
|
18907
|
+
if branch_scope in subscribed:
|
|
18908
|
+
continue
|
|
18909
|
+
|
|
18725
18910
|
# Prepare config
|
|
18726
18911
|
config = dict(node.config)
|
|
18727
18912
|
config = self._resolve_config_expressions(config, node_id)
|
|
18728
18913
|
config = self._auto_inject_connection(node_id, node, config)
|
|
18914
|
+
config["_branch_scope"] = branch_scope
|
|
18729
18915
|
|
|
18730
18916
|
# Connect inputs from upstream
|
|
18731
18917
|
for edge in self.workflow.edges:
|
|
18732
18918
|
if edge.to_node_id == node_id:
|
|
18733
18919
|
all_outputs = self.context.get_all_outputs(edge.from_node_id)
|
|
18920
|
+
source = self.workflow.nodes.get(edge.from_node_id)
|
|
18921
|
+
if (
|
|
18922
|
+
all_outputs
|
|
18923
|
+
and symbol
|
|
18924
|
+
and source
|
|
18925
|
+
and source.node_type in REALTIME_NODE_TYPES
|
|
18926
|
+
):
|
|
18927
|
+
# 합쳐진 시세를 이 아이템의 종목 몫으로 자른다
|
|
18928
|
+
all_outputs = _slice_realtime_outputs(all_outputs, symbol)
|
|
18734
18929
|
for port_name, port_value in all_outputs.items():
|
|
18735
18930
|
self.context.set_output(f"_input_{node_id}", port_name, port_value)
|
|
18736
18931
|
|
|
@@ -18749,13 +18944,31 @@ class WorkflowJob:
|
|
|
18749
18944
|
for port_name, value in outputs.items():
|
|
18750
18945
|
self.context.set_output(node_id, port_name, value)
|
|
18751
18946
|
|
|
18947
|
+
if is_realtime:
|
|
18948
|
+
# 이 종목 구독 완료 — 다음 재구동부터는 이 노드를 건너뛴다
|
|
18949
|
+
subscribed.append(branch_scope)
|
|
18950
|
+
self.context.set_node_state(node_id, _SUBSCRIBED_SCOPES_KEY, subscribed)
|
|
18951
|
+
|
|
18752
18952
|
# Track last result — only from PUBLIC ports. Internal meta
|
|
18753
18953
|
# (_throttle_stats 등)이 유일 출력일 때 그걸 결과로 집으면 Aggregate/Display
|
|
18754
18954
|
# 가 내부 통계를 데이터로 렌더한다.
|
|
18755
18955
|
public = _public_outputs(outputs) if outputs else {}
|
|
18756
18956
|
last_had_public = bool(public)
|
|
18757
18957
|
if public:
|
|
18758
|
-
|
|
18958
|
+
if realtime_branch:
|
|
18959
|
+
row = _realtime_branch_row(public, symbol)
|
|
18960
|
+
if row is None:
|
|
18961
|
+
# 실시간 분기인데 가격 payload 가 없다(예: symbols 목록만 남음)
|
|
18962
|
+
# → 이 아이템은 표에 기여할 실데이터가 없다 → skip.
|
|
18963
|
+
# 그러지 않으면 가격 없는 행이 '실시간 체결가' 표에 낀다.
|
|
18964
|
+
last_had_public = False
|
|
18965
|
+
else:
|
|
18966
|
+
result = row
|
|
18967
|
+
else:
|
|
18968
|
+
result = next(
|
|
18969
|
+
(public[p] for p in _BRANCH_PAYLOAD_PORTS if public.get(p) is not None),
|
|
18970
|
+
next(iter(public.values()), item),
|
|
18971
|
+
)
|
|
18759
18972
|
|
|
18760
18973
|
# 마지막 branch 노드가 공개 출력을 전혀 못 냈으면(예: ThrottleNode 가 이번 사이클
|
|
18761
18974
|
# 을 throttling 중이라 내부 메타만 반환) 이 아이템은 기여할 실데이터가 없다 →
|
|
@@ -18835,13 +19048,7 @@ class WorkflowJob:
|
|
|
18835
19048
|
source_node = self.workflow.nodes.get(source_node_id)
|
|
18836
19049
|
|
|
18837
19050
|
# 실시간 노드는 하위 노드를 자동 트리거
|
|
18838
|
-
|
|
18839
|
-
"RealAccountNode", "RealMarketDataNode", "RealOrderEventNode",
|
|
18840
|
-
"OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
|
|
18841
|
-
"OverseasStockRealMarketDataNode", "OverseasFuturesRealMarketDataNode",
|
|
18842
|
-
"OverseasStockRealOrderEventNode", "OverseasFuturesRealOrderEventNode",
|
|
18843
|
-
}
|
|
18844
|
-
is_realtime_source = source_node and source_node.node_type in auto_trigger_types
|
|
19051
|
+
is_realtime_source = source_node and source_node.node_type in REALTIME_NODE_TYPES
|
|
18845
19052
|
|
|
18846
19053
|
for edge in self.workflow.edges:
|
|
18847
19054
|
if edge.from_node_id == source_node_id:
|
|
@@ -19269,24 +19476,42 @@ class WorkflowJob:
|
|
|
19269
19476
|
# 🆕 트리거된 노드들 + 하위 노드들을 모두 찾아서 실행 순서대로 정렬
|
|
19270
19477
|
nodes_to_execute = self._find_downstream_nodes(trigger_nodes)
|
|
19271
19478
|
print(f" → 실행할 노드 체인: {nodes_to_execute}")
|
|
19272
|
-
|
|
19479
|
+
|
|
19480
|
+
# Split→Aggregate 분기는 노드를 하나씩 재실행해서는 절대 채워지지 않는다.
|
|
19481
|
+
# AggregateNode 의 유일한 입력인 node_state["_collected_items"] 를 쓰는 곳은
|
|
19482
|
+
# _execute_split_branch 한 곳뿐이라, 틱이 하류만 일반 재실행하면 수집 배열이
|
|
19483
|
+
# 최초 실행 시점 값(대개 [])에 영구히 얼어붙는다 → 표가 영원히 빈 채로 남는다.
|
|
19484
|
+
# 체인이 분기에 닿으면 그 Split 분기를 통째로 재구동해 수집을 다시 채운다.
|
|
19485
|
+
split_pairs = self._find_split_aggregate_pairs()
|
|
19486
|
+
chain = set(nodes_to_execute)
|
|
19487
|
+
branch_driven: Set[str] = set()
|
|
19488
|
+
for split_id, aggregate_id in split_pairs.items():
|
|
19489
|
+
branch = self._get_branch_nodes(split_id, aggregate_id)
|
|
19490
|
+
if not (chain & branch) and aggregate_id not in chain:
|
|
19491
|
+
continue
|
|
19492
|
+
split_node = self.workflow.nodes.get(split_id)
|
|
19493
|
+
if not split_node:
|
|
19494
|
+
continue
|
|
19495
|
+
print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
|
|
19496
|
+
await self._execute_split_branch(split_id, split_node, split_pairs, branch)
|
|
19497
|
+
branch_driven |= branch | {split_id, aggregate_id}
|
|
19498
|
+
|
|
19273
19499
|
for node_id in nodes_to_execute:
|
|
19274
19500
|
if not self.context.is_running:
|
|
19275
19501
|
break
|
|
19276
|
-
|
|
19502
|
+
|
|
19277
19503
|
node = self.workflow.nodes.get(node_id)
|
|
19278
19504
|
if not node:
|
|
19279
19505
|
continue
|
|
19280
|
-
|
|
19506
|
+
|
|
19281
19507
|
# 실시간 노드는 이미 실행 중이므로 스킵 (무한 루프 방지)
|
|
19282
|
-
if node.node_type in
|
|
19283
|
-
"RealAccountNode", "RealMarketDataNode", "RealOrderEventNode",
|
|
19284
|
-
"OverseasStockRealAccountNode", "OverseasFuturesRealAccountNode",
|
|
19285
|
-
"OverseasStockRealMarketDataNode", "OverseasFuturesRealMarketDataNode",
|
|
19286
|
-
"OverseasStockRealOrderEventNode", "OverseasFuturesRealOrderEventNode",
|
|
19287
|
-
):
|
|
19508
|
+
if node.node_type in REALTIME_NODE_TYPES:
|
|
19288
19509
|
continue
|
|
19289
|
-
|
|
19510
|
+
|
|
19511
|
+
# 분기 재구동이 이미 실행한 노드는 중복 실행하지 않는다
|
|
19512
|
+
if node_id in branch_driven:
|
|
19513
|
+
continue
|
|
19514
|
+
|
|
19290
19515
|
# Re-execute the triggered node
|
|
19291
19516
|
try:
|
|
19292
19517
|
print(f" ▶ Re-executing: {node_id} ({node.node_type})")
|
|
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-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_risk_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
|