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.
Files changed (37) hide show
  1. {programgarden-1.29.0 → programgarden-1.29.2}/PKG-INFO +1 -1
  2. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/executor.py +261 -36
  3. {programgarden-1.29.0 → programgarden-1.29.2}/pyproject.toml +1 -1
  4. {programgarden-1.29.0 → programgarden-1.29.2}/README.md +0 -0
  5. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/__init__.py +0 -0
  6. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/client.py +0 -0
  8. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/code_worker.py +0 -0
  9. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/context.py +0 -0
  10. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resolver.py +0 -0
  24. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.29.0 → programgarden-1.29.2}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.29.0
3
+ Version: 1.29.2
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -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
- collected_results: List[Any] = []
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
- if result is not _SKIP_BRANCH_ITEM:
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
- result = await self._execute_branch_for_item(
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
- collected_results.append(None)
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
- result = public.get("result") or public.get("value") or next(iter(public.values()), item)
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
- auto_trigger_types = {
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})")
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.29.0"
8
+ version = "1.29.2"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes