programgarden 1.28.0__tar.gz → 1.29.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {programgarden-1.28.0 → programgarden-1.29.0}/PKG-INFO +2 -2
  2. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/__init__.py +1 -1
  3. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/executor.py +378 -111
  4. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resolver.py +150 -24
  5. {programgarden-1.28.0 → programgarden-1.29.0}/pyproject.toml +2 -2
  6. {programgarden-1.28.0 → programgarden-1.29.0}/README.md +0 -0
  7. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/client.py +0 -0
  9. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/context.py +0 -0
  11. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.28.0
3
+ Version: 1.29.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,7 +16,7 @@ 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
18
  Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.20.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.21.0,<2.0.0)
20
20
  Requires-Dist: programgarden-finance (>=1.6.14,<2.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)
@@ -91,7 +91,7 @@ except ImportError:
91
91
  # Community package not installed
92
92
  pass
93
93
 
94
- __version__ = "1.28.0"
94
+ __version__ = "1.29.0"
95
95
  __all__ = [
96
96
  # Core
97
97
  "ProgramGarden",
@@ -850,16 +850,37 @@ class ThrottleNodeExecutor(NodeExecutorBase):
850
850
  ) -> Dict[str, Any]:
851
851
  """Process data pass-through"""
852
852
  from programgarden_core.bases.listener import NodeState
853
-
853
+
854
854
  now = datetime.now()
855
+
856
+ # 상류에 흘릴 실데이터가 없으면(예: 실시간 시세 노드가 아직 틱 대기) '통과했다'고
857
+ # data-형 output 을 내지 않는다. 예전엔 여기서 outputs={} 에 _throttle_stats 만
858
+ # 실어 방출했고, 하류 collector 가 그 내부 통계를 데이터로 오인했다(passed:True
859
+ # 껍데기가 표에 렌더). pending 이면 내부 메타만 돌려주고 cooldown 도 리셋 안 한다
860
+ # (실제로 통과한 게 없으므로). — 2026-07-14 runtime-wiring fix (생산자 측).
861
+ if not input_data:
862
+ context.log(
863
+ "debug",
864
+ "Throttle pass-through skipped: no upstream data yet (pending)",
865
+ node_id,
866
+ )
867
+ return {
868
+ "_throttled": True,
869
+ "_throttle_stats": {
870
+ "skipped_count": throttle_state.get("skipped_count", 0),
871
+ "passed": False,
872
+ "reason": "no upstream data yet",
873
+ },
874
+ }
875
+
855
876
  throttle_state["last_passed_at"] = now.isoformat()
856
877
  throttle_state["pending_data"] = None
857
878
  # Keep skipped_count for cumulative stats
858
-
879
+
859
880
  context.set_node_state(node_id, state_key, throttle_state)
860
-
881
+
861
882
  # Pass input data as output (transparent proxy)
862
- outputs = dict(input_data) if input_data else {}
883
+ outputs = dict(input_data)
863
884
  outputs["_throttle_stats"] = {
864
885
  "skipped_count": throttle_state.get("skipped_count", 0),
865
886
  "last_passed_at": now.isoformat(),
@@ -913,6 +934,128 @@ class ThrottleNodeExecutor(NodeExecutorBase):
913
934
  return input_data
914
935
 
915
936
 
937
+ # Priority-ordered output ports a SplitNode may iterate when its ``array`` input
938
+ # is not explicitly bound. Canonical single-array producers first (Watchlist →
939
+ # symbols, Condition → values, …), then account multi-array ports.
940
+ _SPLIT_ARRAY_KEYS = [
941
+ "array", "symbols", "values", "data", "items",
942
+ "held_symbols", "positions", "open_orders",
943
+ ]
944
+
945
+
946
+ def _pick_split_array(upstream: Dict[str, Any], *, source: str) -> List[Any]:
947
+ """Deterministically pick the array a SplitNode should iterate from an
948
+ upstream output dict.
949
+
950
+ Raises ``RuntimeError`` with a clear reason on ambiguity/absence — it never
951
+ silently returns ``[]`` or grabs an arbitrary "first list" (that made the
952
+ iteration source depend on dict ordering: an account node exposing
953
+ ``held_symbols``/``positions``/``open_orders`` would iterate whichever the
954
+ executor happened to emit first, and an explicit ``array`` binding was
955
+ ignored entirely — see the 2026-07-14 runtime-wiring fix).
956
+ """
957
+ candidates = [k for k in _SPLIT_ARRAY_KEYS if isinstance(upstream.get(k), list)]
958
+ if len(candidates) == 1:
959
+ return upstream[candidates[0]]
960
+ if not candidates:
961
+ raise RuntimeError(
962
+ f"SplitNode has no array to split: upstream ({source}) exposes no "
963
+ f"array output among {_SPLIT_ARRAY_KEYS}. Bind split.array explicitly "
964
+ "(e.g. array: '{{ nodes.<id>.held_symbols }}') or wire an "
965
+ "array-producing node upstream."
966
+ )
967
+ raise RuntimeError(
968
+ f"SplitNode array source is ambiguous: upstream ({source}) exposes "
969
+ f"multiple candidate arrays {candidates}. Bind split.array explicitly to "
970
+ f"pick one (e.g. array: '{{{{ nodes.<id>.{candidates[0]} }}}}')."
971
+ )
972
+
973
+
974
+ def _public_outputs(outputs: Dict[str, Any]) -> Dict[str, Any]:
975
+ """Drop internal/meta ports (``_``-prefixed, e.g. ``_throttle_stats`` /
976
+ ``_throttled``) so they never leak downstream as data.
977
+
978
+ The ``_`` prefix is the repo-wide convention for internal port metadata
979
+ (see NodeExecutorBase._collect_input_data, which already filters inputs this
980
+ way). This centralizes the same filter for the *output* side so ANY node's
981
+ meta — not just ThrottleNode's stats — is excluded consistently. Without it,
982
+ a ThrottleNode that has nothing to pass (upstream still pending) returns
983
+ only ``_throttle_stats`` and a downstream collector grabs that stats dict as
984
+ if it were the payload (2026-07-14 runtime-wiring fix).
985
+ """
986
+ return {
987
+ k: v for k, v in outputs.items()
988
+ if not (isinstance(k, str) and k.startswith("_"))
989
+ }
990
+
991
+
992
+ # ── 해외주식 거래소 표기 정규화 (2026-07-14 결함3) ────────────────────────────
993
+ # LS 는 거래소를 갈래마다 다른 표현으로 준다: REST balance(COSOQ00201) MktTpNm 은
994
+ # 한글 표시명('뉴욕'/'나스닥'), FcurrMktCode 는 LS 코드('81'/'82'/'83'), screener/
995
+ # universe 갈래는 yfinance 접미사(NMS/NYQ/…). 같은 종목인데 시점마다 값이 갈려
996
+ # 표·비교·구독이 조용히 깨진다. producer 경계에서 (표시명, LS코드) 두 표현을 **둘 다**
997
+ # 확정하고, 구독·주문 소비자는 어느 쪽을 받든 코드로 환원한다(미매핑은 raise).
998
+ #
999
+ # ⚠️ 국내/선물은 이번 릴리스에서 건드리지 않는다(정상 동작 중). 전역 네이밍 통일
1000
+ # (market_code→exchange_code)은 계획서 후속 항목이다.
1001
+ # ⚠️ LS 에서 **AMEX 의 코드는 81** 이다 (주문 OrdMktCode=Literal["81","82"], GSC "81=
1002
+ # NYSE/AMEX, 82=NASDAQ"). '83' 은 LS 가 반환/수용하지 않는 **유령 코드**라 제거했다.
1003
+ # - 이름→코드는 many-to-one(NYSE→81, AMEX→81, NASDAQ→82) — 무손실(기계 경로엔 코드만 필요).
1004
+ # - 코드→이름은 1:1 아님(81=NYSE 이자 AMEX). **표시명을 코드에서 역산하면 AMEX 가 NYSE 로
1005
+ # 보일 수 있다**(한계 — 계획서에 명시). 정확한 표시명이 필요하면 이름 소스(MktTpNm 정규화)
1006
+ # 를 쓰되, REST↔tracker 값 일치를 위해 producer 는 공통 소스(코드)에서 파생한다.
1007
+ _OVERSEAS_STOCK_EXCHANGE = {
1008
+ # LS 코드 → (표시명, 코드). 81 은 NYSE 로 표시(AMEX 도 81이나 코드만으론 구분 불가).
1009
+ "81": ("NYSE", "81"), "82": ("NASDAQ", "82"),
1010
+ # 영문 표시명 → 코드 (이름 소스는 AMEX 를 정확히 구분)
1011
+ "NYSE": ("NYSE", "81"), "AMEX": ("AMEX", "81"), "NASDAQ": ("NASDAQ", "82"),
1012
+ # 한글 표시명 (MktTpNm) → 영문명+코드
1013
+ "뉴욕": ("NYSE", "81"), "아멕스": ("AMEX", "81"), "나스닥": ("NASDAQ", "82"),
1014
+ # yfinance 접미사 (screener/universe 갈래)
1015
+ "NMS": ("NASDAQ", "82"), "NGM": ("NASDAQ", "82"), "NCM": ("NASDAQ", "82"),
1016
+ "NYQ": ("NYSE", "81"), "ASE": ("AMEX", "81"),
1017
+ }
1018
+
1019
+
1020
+ def normalize_overseas_stock_exchange(raw: Any) -> "tuple[Optional[str], Optional[str]]":
1021
+ """해외주식 거래소 원시값 → (표시명, LS코드). 매핑 불가면 (None, None).
1022
+
1023
+ 코드('82')·영문('NASDAQ')·한글('나스닥')·yfinance('NMS')를 모두 받아 정규화한다.
1024
+ """
1025
+ if raw is None:
1026
+ return (None, None)
1027
+ s = str(raw).strip()
1028
+ hit = _OVERSEAS_STOCK_EXCHANGE.get(s) or _OVERSEAS_STOCK_EXCHANGE.get(s.upper())
1029
+ return hit if hit else (None, None)
1030
+
1031
+
1032
+ def overseas_stock_exchange_pair(raw: Any) -> "tuple[str, str]":
1033
+ """producer 경계용: (영문 표시명, LS코드) 를 확정해 둘 다 반환.
1034
+
1035
+ 소스는 FcurrMktCode(코드 캐리어)를 권장한다. 매핑 불가한 값은:
1036
+ - 한글/미지 **표시명을 데이터에 박지 않는다**(i18n 부채 방지 — 코드·데이터=영어 규율).
1037
+ - raw 가 LS 코드(숫자)면 코드를 그대로 보존(권한 있는 코드 캐리어), 표시명은 코드
1038
+ 문자열로 둔다(영어/숫자, 한글 아님). 이름형 미지 값이면 빈 값.
1039
+ 코드가 필요한 소비자(구독/주문)는 미매핑 코드에서 raise 한다 — 조용히 틀린 코드로
1040
+ 대체하지 않는다.
1041
+ """
1042
+ name, code = normalize_overseas_stock_exchange(raw)
1043
+ if name is not None:
1044
+ return (name, code)
1045
+ raw_s = str(raw).strip() if raw is not None else ""
1046
+ if raw_s.isdigit():
1047
+ return (raw_s, raw_s) # 미지 LS 코드 — 코드 보존, 한글 아님
1048
+ return ("", "") # 이름형 미지 표기 → 데이터에 안 박음(빈 값)
1049
+
1050
+
1051
+ # Sentinel: a split branch item that produced no real (public) data this cycle
1052
+ # and must NOT contribute a row to the AggregateNode/Display. Used when the
1053
+ # terminal branch node emits only internal meta (e.g. ThrottleNode is throttling
1054
+ # or the realtime source is still pending) — showing that item as a row makes a
1055
+ # "실시간 체결가" table render a priceless / heterogeneous row (2026-07-14 fix).
1056
+ _SKIP_BRANCH_ITEM = object()
1057
+
1058
+
916
1059
  class SplitNodeExecutor(NodeExecutorBase):
917
1060
  """
918
1061
  SplitNode executor
@@ -936,33 +1079,43 @@ class SplitNodeExecutor(NodeExecutorBase):
936
1079
  context: ExecutionContext,
937
1080
  **kwargs,
938
1081
  ) -> Dict[str, Any]:
939
- # Get array from config or input port
940
- input_array = config.get("array", [])
941
-
942
- # If array binding expression, resolve from context
943
- if isinstance(input_array, str) and "{{" in input_array:
944
- expr_context = context.get_expression_context()
945
- evaluator = ExpressionEvaluator(expr_context)
946
- try:
947
- input_array = evaluator.evaluate(input_array) or []
948
- except Exception as e:
949
- context.log("warning", f"Array expression evaluation failed: {e}", node_id)
1082
+ # NOTE: For the common Split→Aggregate branch pattern this executor is
1083
+ # NOT the runtime source of truth — WorkflowJob._execute_split_branch
1084
+ # resolves the array and drives iteration, and _execute_branch_for_item
1085
+ # sets item/index/total/items/_array directly. This method only runs on
1086
+ # the legacy/standalone path; it shares _pick_split_array with the branch
1087
+ # driver so the two can never diverge again.
1088
+ input_array = None
1089
+
1090
+ # 1순위: 명시적 array 바인딩 (있으면 상류보다 우선).
1091
+ array_cfg = config.get("array")
1092
+ if array_cfg is not None:
1093
+ if isinstance(array_cfg, str) and "{{" in array_cfg:
1094
+ expr_context = context.get_expression_context()
1095
+ evaluator = ExpressionEvaluator(expr_context)
1096
+ try:
1097
+ array_cfg = evaluator.evaluate(array_cfg)
1098
+ except Exception as e:
1099
+ raise RuntimeError(
1100
+ f"SplitNode {node_id}: array binding '{config.get('array')}' "
1101
+ f"failed to evaluate: {e}"
1102
+ ) from e
1103
+ if isinstance(array_cfg, list):
1104
+ input_array = array_cfg
1105
+ elif array_cfg is None:
950
1106
  input_array = []
1107
+ else:
1108
+ input_array = [array_cfg] # scalar → single explicit item
951
1109
 
952
- # Also check input port (upstream node output)
953
- if not input_array:
1110
+ # 2순위: 상류 출력 KNOWN 키 우선순위로 결정화 (모호/부재 raise).
1111
+ if input_array is None:
954
1112
  input_data = context.get_output(f"_input_{node_id}", "input")
955
1113
  if isinstance(input_data, list):
956
1114
  input_array = input_data
957
1115
  elif isinstance(input_data, dict):
958
- # Check for array fields in input
959
- for key in ["array", "symbols", "values", "data", "items"]:
960
- if key in input_data and isinstance(input_data[key], list):
961
- input_array = input_data[key]
962
- break
963
-
964
- if not isinstance(input_array, list):
965
- input_array = [input_array] if input_array else []
1116
+ input_array = _pick_split_array(input_data, source=f"input of '{node_id}'")
1117
+ else:
1118
+ input_array = [input_data] if input_data else []
966
1119
 
967
1120
  # Get current item context (set by _execute_main_flow during iteration)
968
1121
  split_context = context.get_node_state(node_id, "_split_context") or {
@@ -981,8 +1134,10 @@ class SplitNodeExecutor(NodeExecutorBase):
981
1134
  "item": split_context["item"],
982
1135
  "index": split_context["index"],
983
1136
  "total": split_context["total"],
984
- # Expose full array via both legacy name and the documented schema port.
985
- "_array": input_array,
1137
+ # Full input array via the documented schema port. (The undeclared
1138
+ # legacy `_array` alias was dropped — it had no reader and its `_`
1139
+ # prefix collided with the internal-meta filter; only declared ports
1140
+ # are emitted so declaration == runtime.)
986
1141
  "items": input_array,
987
1142
  }
988
1143
 
@@ -4443,6 +4598,10 @@ class AccountNodeExecutor(NodeExecutorBase):
4443
4598
  if not symbol:
4444
4599
  continue
4445
4600
 
4601
+ # 거래소 표기: FcurrMktCode(코드 캐리어) → (영문 표시명, LS 코드). 예전엔
4602
+ # exchange 에 MktTpNm(한글 '나스닥')을 실어 RealAccount 갈래(코드/영문)와
4603
+ # 갈렸고 한글이 엔진 데이터에 박혔다. market 필드는 종전 표기 유지(별도 필드).
4604
+ _ex_name, _ex_code = overseas_stock_exchange_pair(getattr(item, 'FcurrMktCode', ''))
4446
4605
  positions.append({
4447
4606
  "symbol": symbol,
4448
4607
  "name": item.JpnMktHanglIsuNm.strip() if item.JpnMktHanglIsuNm else symbol,
@@ -4456,7 +4615,8 @@ class AccountNodeExecutor(NodeExecutorBase):
4456
4615
  "pnl_amount": item.FcurrEvalPnlAmt,
4457
4616
  "currency": item.CrcyCode,
4458
4617
  "market": item.MktTpNm.strip() if item.MktTpNm else "",
4459
- "exchange": item.MktTpNm.strip() if item.MktTpNm else "NASDAQ", # NewOrderNode 호환
4618
+ "exchange": _ex_name, # 영문 표시명 (NASDAQ)
4619
+ "exchange_code": _ex_code, # LS 코드 (82)
4460
4620
  "eval_amount": item.FcurrEvalAmt,
4461
4621
  "purchase_amount": item.FcurrBuyAmt,
4462
4622
  })
@@ -4464,7 +4624,8 @@ class AccountNodeExecutor(NodeExecutorBase):
4464
4624
  # held_symbols 는 선언된 출력 포트다. 예전엔 여기서 계산만 하고 **반환 dict 에 싣지
4465
4625
  # 않아** 늘 비어 있었다 — 바인딩하면 정적 검증은 통과하고 런타임엔 None 이었다.
4466
4626
  held_symbols = [
4467
- {"exchange": p.get("exchange", ""), "symbol": p["symbol"]} for p in positions
4627
+ {"exchange": p.get("exchange", ""), "exchange_code": p.get("exchange_code", ""), "symbol": p["symbol"]}
4628
+ for p in positions
4468
4629
  ]
4469
4630
 
4470
4631
  # block2 = 전체 평가 요약. `orderable_amount` is set later by
@@ -5360,10 +5521,14 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5360
5521
  for sym, pos in positions.items():
5361
5522
  quantity = pos.quantity
5362
5523
  current_price = float(pos.current_price)
5524
+ # tracker 갈래(_get_overseas_stock_tracker_data)와 **같은 소스(market_code)
5525
+ # 에서 같은 값 표현**을 낸다 — exchange=영문 표시명, exchange_code=LS 코드.
5526
+ _ex_name, _ex_code = overseas_stock_exchange_pair(getattr(pos, 'market_code', ''))
5363
5527
  serialized_positions.append({
5364
5528
  "symbol": sym,
5365
- "exchange": getattr(pos, 'market_code', 'NASDAQ'),
5366
- "market_code": getattr(pos, 'market_code', ''),
5529
+ "exchange": _ex_name, # 영문 표시명
5530
+ "exchange_code": _ex_code, # LS 코드
5531
+ "market_code": getattr(pos, 'market_code', ''), # 후속: exchange_code 로 수렴/제거
5367
5532
  "name": getattr(pos, 'symbol_name', sym),
5368
5533
  "qty": quantity,
5369
5534
  "quantity": quantity, # NewOrderNode 호환
@@ -5828,14 +5993,17 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5828
5993
  for symbol, pos in tracker.get_positions().items():
5829
5994
  quantity = pos.quantity
5830
5995
  current_price = float(pos.current_price)
5996
+ # 거래소 표기 정규화: FcurrMktCode(코드 캐리어) → (영문 표시명, LS 코드).
5997
+ # REST 스냅샷 갈래(_ls_stock_with_tracker)와 **같은 키 + 같은 값 표현**이어야
5998
+ # 한다. 예전엔 exchange 에 raw market_code 를 실어, 스냅샷(이름)과 틱(코드)이
5999
+ # 시점마다 갈렸다(주문/표가 조용히 깨짐).
6000
+ _ex_name, _ex_code = overseas_stock_exchange_pair(getattr(pos, 'market_code', ''))
5831
6001
  positions.append({
5832
6002
  "symbol": symbol,
5833
6003
  "name": getattr(pos, 'name', getattr(pos, 'symbol_name', symbol)),
5834
- # REST 스냅샷 갈래(_ls_stock_with_tracker)와 **같은 키 집합**이어야 한다.
5835
- # 그러면 같은 포트가 스냅샷일 땐 exchange/quantity 를 주고 실시간 틱일 땐
5836
- # 주는, 시점마다 모양이 바뀌는 출력이 된다 (주문 노드가 조용히 깨진다).
5837
- "exchange": getattr(pos, 'market_code', 'NASDAQ'),
5838
- "market_code": getattr(pos, 'market_code', ''),
6004
+ "exchange": _ex_name, # 영문 표시명 (NASDAQ)
6005
+ "exchange_code": _ex_code, # LS 코드 (82)
6006
+ "market_code": getattr(pos, 'market_code', ''), # 후속: exchange_code 수렴/제거
5839
6007
  "qty": quantity,
5840
6008
  "quantity": quantity, # NewOrderNode 호환
5841
6009
  "price": current_price, # NewOrderNode 호환
@@ -5849,7 +6017,8 @@ class RealAccountNodeExecutor(NodeExecutorBase):
5849
6017
  })
5850
6018
 
5851
6019
  held_symbols = [
5852
- {"exchange": p["exchange"], "symbol": p["symbol"]} for p in positions
6020
+ {"exchange": p["exchange"], "exchange_code": p["exchange_code"], "symbol": p["symbol"]}
6021
+ for p in positions
5853
6022
  ]
5854
6023
 
5855
6024
  # balance를 JSON 직렬화 가능한 형태로 변환
@@ -6173,10 +6342,15 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6173
6342
  symbol = entry.get("symbol", "")
6174
6343
  exchange = entry.get("exchange", "")
6175
6344
  symbols.append(symbol)
6176
- symbols_with_exchange.append({"symbol": symbol, "exchange": exchange})
6345
+ # exchange_code(있으면 권한 있는 LS 코드)를 함께 통과시켜 하류 구독이
6346
+ # 이름 역매핑 없이도 정확한 코드를 쓰게 한다.
6347
+ symbols_with_exchange.append({
6348
+ "symbol": symbol, "exchange": exchange,
6349
+ "exchange_code": entry.get("exchange_code", ""),
6350
+ })
6177
6351
  elif isinstance(entry, str):
6178
6352
  symbols.append(entry)
6179
- symbols_with_exchange.append({"symbol": entry, "exchange": ""})
6353
+ symbols_with_exchange.append({"symbol": entry, "exchange": "", "exchange_code": ""})
6180
6354
 
6181
6355
  # ========================================
6182
6356
  # 3. stay_connected 설정
@@ -6250,9 +6424,11 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6250
6424
  subscribe_symbols = []
6251
6425
  for entry in symbols_with_exchange:
6252
6426
  symbol = entry.get("symbol", "")
6253
- exchange = entry.get("exchange", "")
6254
- # 거래소 코드 매핑: NASDAQ=82, NYSE=81, AMEX=83
6255
- exchange_code = self._get_stock_exchange_code(exchange)
6427
+ # exchange_code(우선) 또는 exchange(이름/코드) LS 코드(81/82). 미매핑은
6428
+ # 조용히 기본값으로 바꾸지 않고 raise(종목·거래소 명시).
6429
+ exchange_code = self._get_stock_exchange_code(
6430
+ entry.get("exchange", ""), entry.get("exchange_code", ""), symbol
6431
+ )
6256
6432
  subscribe_symbols.append(f"{exchange_code}{symbol}")
6257
6433
 
6258
6434
  # M-11: OHLCV 데이터를 context.node_state에 저장 (클로저 메모리 관리)
@@ -6320,6 +6496,7 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6320
6496
  ohlcv_data = {s: [bar] for s, bar in ohlcv_bars.items()}
6321
6497
 
6322
6498
  # context에 업데이트
6499
+ context.set_output(node_id, "symbols", symbols_raw) # 선언 포트 — 틱 시 방출
6323
6500
  context.set_output(node_id, "ohlcv_data", ohlcv_data)
6324
6501
  context.set_output(node_id, "data", ohlcv_data)
6325
6502
 
@@ -6373,11 +6550,13 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6373
6550
  context.log("info", f"GSC subscription active - waiting for ticks...", node_id)
6374
6551
 
6375
6552
  # 초기 반환: 빈 OHLCV 데이터 (체결 시 콜백에서 업데이트)
6376
- return {
6377
- "symbols": symbols_raw,
6378
- "ohlcv_data": {},
6379
- "data": {},
6380
- }
6553
+ # pending 을 1급 신호로 방출한다: 아직 틱이 없어 흘릴 **실데이터가 없다.**
6554
+ # 예전엔 {symbols, ohlcv_data:{}, data:{}} 처럼 **공개 데이터 포트를 빈 값으로**
6555
+ # 냈고, 하류 Throttle→Aggregate→Display 가 그 빈 dict 를 '체결가' 행으로 렌더했다
6556
+ # (제목은 '실시간 체결가'인데 내용이 빈 dict — 결함2 의 원증상). 이제 내부 `_pending`
6557
+ # 신호만 내면 Throttle 이 통과시킬 실데이터가 없어 branch 가 skip 되고, 표에는 실제
6558
+ # 틱이 온 종목만(또는 정직한 빈 표) 뜬다. 선언 포트 symbols 는 틱 경로에서 방출한다.
6559
+ return {"_pending": True}
6381
6560
 
6382
6561
  async def _execute_futures(
6383
6562
  self,
@@ -6484,6 +6663,7 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6484
6663
  # OHLCV 데이터 형식으로 변환
6485
6664
  ohlcv_data = {s: [bar] for s, bar in ohlcv_bars.items()}
6486
6665
 
6666
+ context.set_output(node_id, "symbols", symbols_raw) # 선언 포트 — 틱 시 방출
6487
6667
  context.set_output(node_id, "ohlcv_data", ohlcv_data)
6488
6668
  context.set_output(node_id, "data", ohlcv_data)
6489
6669
 
@@ -6535,23 +6715,40 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6535
6715
  context.log("info", f"OVC subscription active - waiting for ticks...", node_id)
6536
6716
 
6537
6717
  # 초기 반환: 빈 OHLCV 데이터
6538
- return {
6539
- "symbols": symbols_raw,
6540
- "ohlcv_data": {},
6541
- "data": {},
6542
- }
6718
+ # pending 을 1급 신호로 방출한다: 아직 틱이 없어 흘릴 **실데이터가 없다.**
6719
+ # 예전엔 {symbols, ohlcv_data:{}, data:{}} 처럼 **공개 데이터 포트를 빈 값으로**
6720
+ # 냈고, 하류 Throttle→Aggregate→Display 가 그 빈 dict 를 '체결가' 행으로 렌더했다
6721
+ # (제목은 '실시간 체결가'인데 내용이 빈 dict — 결함2 의 원증상). 이제 내부 `_pending`
6722
+ # 신호만 내면 Throttle 이 통과시킬 실데이터가 없어 branch 가 skip 되고, 표에는 실제
6723
+ # 틱이 온 종목만(또는 정직한 빈 표) 뜬다. 선언 포트 symbols 는 틱 경로에서 방출한다.
6724
+ return {"_pending": True}
6543
6725
 
6544
- def _get_stock_exchange_code(self, exchange: str) -> str:
6545
- """거래소명을 LS증권 거래소 코드로 변환"""
6546
- exchange_map = {
6547
- "NASDAQ": "82",
6548
- "NYSE": "81",
6549
- "AMEX": "83",
6550
- "82": "82",
6551
- "81": "81",
6552
- "83": "83",
6553
- }
6554
- return exchange_map.get(exchange.upper(), "82") # 기본값 NASDAQ
6726
+ def _get_stock_exchange_code(self, exchange: str, exchange_code: str = "", symbol: str = "") -> str:
6727
+ """구독/주문용 LS 거래소 코드(81/82)로 환원.
6728
+
6729
+ exchange_code(있으면 권한 있는 코드) → exchange(이름/코드) 순으로 시도한다.
6730
+ 비어 있으면(미지정) 관례상 NASDAQ(82). **비어 있지 않은데 매핑 불가하면 조용히
6731
+ 기본값으로 바꾸지 않고 raise** — 종목·거래소를 명시해 사용자가 무엇을 고칠지 알게
6732
+ 한다. (예전엔 미매핑을 조용히 '82' 로 떨궈 NYSE/AMEX 종목이 NASDAQ 로 잘못 구독됐다.)
6733
+
6734
+ 판단(전체 실패 vs 종목 제외): 미지원 거래소(도쿄 등) 1종목 때문에 **하드 실패**한다.
6735
+ '내 보유종목 실시간 감시' 워크플로우에서 조용히/부분적으로 일부만 감시하면 사용자는
6736
+ 감시되는 알지만 아니다 — 그 침묵이 더 위험하다. 에러가 어느 종목·거래소인지
6737
+ 명시하므로 사용자가 그 종목을 빼거나 거래소를 고치면 된다.
6738
+ """
6739
+ for cand in (exchange_code, exchange):
6740
+ if cand is None or str(cand).strip() == "":
6741
+ continue
6742
+ _name, code = normalize_overseas_stock_exchange(cand)
6743
+ if code:
6744
+ return code
6745
+ raise RuntimeError(
6746
+ f"해외주식 실시간 구독/주문 거래소를 LS 코드로 환원할 수 없습니다: "
6747
+ f"symbol={symbol or '?'!r} exchange={cand!r}. LS 해외주식은 "
6748
+ f"NYSE/NASDAQ/AMEX(코드 81/82)만 지원합니다 — 이 종목의 거래소를 확인하거나 "
6749
+ f"워크플로우에서 제외하세요."
6750
+ )
6751
+ return "82" # 미지정(빈 값) → 관례상 NASDAQ
6555
6752
 
6556
6753
  async def _execute_korea_stock(
6557
6754
  self,
@@ -6629,6 +6826,7 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6629
6826
  context.set_node_state(node_id, "ohlcv_bars", ohlcv_bars)
6630
6827
 
6631
6828
  ohlcv_data = {s: [bar] for s, bar in ohlcv_bars.items()}
6829
+ context.set_output(node_id, "symbols", symbols_raw) # 선언 포트 — 틱 시 방출
6632
6830
  context.set_output(node_id, "ohlcv_data", ohlcv_data)
6633
6831
  context.set_output(node_id, "data", ohlcv_data)
6634
6832
 
@@ -6683,11 +6881,13 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6683
6881
  context.set_node_state(node_id, "subscribe_symbols", subscribe_symbols)
6684
6882
  context.log("info", f"S3_/K3_ subscription active - waiting for ticks...", node_id)
6685
6883
 
6686
- return {
6687
- "symbols": symbols_raw,
6688
- "ohlcv_data": {},
6689
- "data": {},
6690
- }
6884
+ # pending 을 1급 신호로 방출한다: 아직 틱이 없어 흘릴 **실데이터가 없다.**
6885
+ # 예전엔 {symbols, ohlcv_data:{}, data:{}} 처럼 **공개 데이터 포트를 빈 값으로**
6886
+ # 냈고, 하류 Throttle→Aggregate→Display 가 그 빈 dict 를 '체결가' 행으로 렌더했다
6887
+ # (제목은 '실시간 체결가'인데 내용이 빈 dict — 결함2 의 원증상). 이제 내부 `_pending`
6888
+ # 신호만 내면 Throttle 이 통과시킬 실데이터가 없어 branch 가 skip 되고, 표에는 실제
6889
+ # 틱이 온 종목만(또는 정직한 빈 표) 뜬다. 선언 포트 symbols 는 틱 경로에서 방출한다.
6890
+ return {"_pending": True}
6691
6891
 
6692
6892
  def _resolve_symbols(
6693
6893
  self,
@@ -6697,26 +6897,49 @@ class RealMarketDataNodeExecutor(NodeExecutorBase):
6697
6897
  watchlist_output: Optional[Dict[str, Any]],
6698
6898
  ) -> List[Dict[str, str]]:
6699
6899
  """
6700
- Symbols 획득 (필드 우선, 포트 폴백 패턴)
6701
-
6702
- 1. 노드 config의 symbols 필드 확인
6703
- 2. WatchlistNode 출력에서 symbols 가져오기
6704
- 3. AccountNode/RealAccountNode의 held_symbols 가져오기
6900
+ Symbols 획득 (선언된 `symbol` 우선, 포트 폴백)
6901
+
6902
+ 0. 노드가 선언한 단일 `symbol`({exchange,symbol} dict) — item-based /
6903
+ SplitNode fan-out per-branch 입력. 선언==런타임: 3개 realtime
6904
+ market 노드가 InputPort("symbol") 를 선언하므로 executor 가 반드시
6905
+ 읽는다(과거엔 안 읽어 조용히 상류 held_symbols 전체 폴백을 탔다 —
6906
+ Split fan-out 이 장식이 됐다).
6907
+ 1. Input 포트(context input) — 포트 경로
6908
+ 2. WatchlistNode 출력 — 포트 경로
6909
+ 3. AccountNode/RealAccountNode 의 held_symbols (자동 iterate 폴백)
6910
+
6911
+ 미선언 `symbols`(복수) config 필드는 제거됐다: realtime market 노드 어느
6912
+ 것도 선언하지 않고 코퍼스 사용 0건 — consumed-but-not-declared 드리프트
6913
+ 였다. 정본 per-node 입력은 단일 `symbol` 이다.
6705
6914
  """
6706
- # 1. 필드에서 직접 설정된 경우
6707
- if config.get("symbols"):
6708
- return config["symbols"]
6709
-
6710
- # 2. Input 포트에서 받기 (context input)
6915
+ from programgarden_core.exceptions import ValidationError
6916
+
6917
+ # 0. 선언된 `symbol`(단수 dict): config 바인딩 또는 wired 입력 포트.
6918
+ single = config.get("symbol")
6919
+ if single in (None, "", {}):
6920
+ single = context.get_output(f"_input_{node_id}", "symbol")
6921
+ if single not in (None, "", {}):
6922
+ # shape 강제: {exchange, symbol} dict 이어야 한다. 문자열/스칼라를
6923
+ # 조용히 넘기지 않는다(silent 금지 — 사유 담아 raise).
6924
+ if not isinstance(single, dict) or not single.get("symbol"):
6925
+ raise ValidationError(
6926
+ f"Node '{node_id}': 'symbol' 은 {{exchange, symbol}} dict 여야 한다 "
6927
+ f"({{{{ nodes.<split>.item }}}} 에 바인딩); 받은 값 "
6928
+ f"{type(single).__name__}: {single!r}",
6929
+ node_id=node_id,
6930
+ )
6931
+ return [single]
6932
+
6933
+ # 1. Input 포트에서 받기 (context input) — 포트 경로
6711
6934
  symbols_input = context.get_output(f"_input_{node_id}", "symbols")
6712
6935
  if symbols_input:
6713
6936
  return symbols_input
6714
-
6715
- # 3. WatchlistNode에서 받기
6937
+
6938
+ # 2. WatchlistNode에서 받기 — 포트 경로
6716
6939
  if watchlist_output and watchlist_output.get("symbols"):
6717
6940
  return watchlist_output["symbols"]
6718
-
6719
- # 4. AccountNode/RealAccountNode에서 held_symbols 가져오기
6941
+
6942
+ # 3. AccountNode/RealAccountNode에서 held_symbols 가져오기
6720
6943
  for account_type in (
6721
6944
  "RealAccountNode",
6722
6945
  "OverseasStockRealAccountNode",
@@ -18322,22 +18545,58 @@ class WorkflowJob:
18322
18545
  state=NodeState.RUNNING,
18323
18546
  )
18324
18547
 
18325
- # Get input array from upstream node
18326
- input_array = []
18327
- for edge in self.workflow.edges:
18328
- if edge.to_node_id == split_id:
18329
- upstream_outputs = self.context.get_all_outputs(edge.from_node_id)
18330
- # Look for array in outputs
18331
- for key in ["symbols", "values", "array", "data", "items"]:
18332
- if key in upstream_outputs and isinstance(upstream_outputs[key], list):
18333
- input_array = upstream_outputs[key]
18334
- break
18335
- if not input_array and upstream_outputs:
18336
- # Check if single output is a list
18337
- first_val = next(iter(upstream_outputs.values()), None)
18338
- if isinstance(first_val, list):
18339
- input_array = first_val
18340
- break
18548
+ # Get SplitNode config (array binding + iteration params)
18549
+ config = dict(split_node.config)
18550
+ parallel = config.get("parallel", False)
18551
+ delay_ms = config.get("delay_ms", 0)
18552
+ continue_on_error = config.get("continue_on_error", True)
18553
+
18554
+ # Resolve the array to split the single source of truth for this
18555
+ # branch: per-item item/index/total AND the items/_array output ports
18556
+ # all derive from it. Historically the iteration driver ignored
18557
+ # config.array and grabbed the "first list" from upstream, so an
18558
+ # explicit binding was silently dropped and account nodes iterated a
18559
+ # nondeterministic port (held_symbols vs positions depended on dict
18560
+ # ordering) — 2026-07-14 runtime-wiring fix.
18561
+ input_array = None
18562
+
18563
+ # 1순위: 명시적 array 바인딩 ({{ }} 평가). 있으면 상류보다 우선.
18564
+ array_cfg = config.get("array")
18565
+ if array_cfg is not None:
18566
+ if isinstance(array_cfg, str) and "{{" in array_cfg:
18567
+ evaluator = ExpressionEvaluator(self.context.get_expression_context())
18568
+ try:
18569
+ array_cfg = evaluator.evaluate(array_cfg)
18570
+ except Exception as e:
18571
+ raise RuntimeError(
18572
+ f"SplitNode {split_id}: array binding "
18573
+ f"'{config.get('array')}' failed to evaluate: {e}"
18574
+ ) from e
18575
+ if isinstance(array_cfg, list):
18576
+ input_array = array_cfg
18577
+ elif array_cfg is None:
18578
+ input_array = []
18579
+ else:
18580
+ input_array = [array_cfg] # scalar → single explicit item
18581
+
18582
+ # 2순위: 상류 엣지 출력 — KNOWN 키 우선순위로 결정화 (모호/부재 시 raise).
18583
+ if input_array is None:
18584
+ upstream_id = None
18585
+ upstream_outputs: Dict[str, Any] = {}
18586
+ for edge in self.workflow.edges:
18587
+ if edge.to_node_id == split_id:
18588
+ upstream_id = edge.from_node_id
18589
+ upstream_outputs = self.context.get_all_outputs(edge.from_node_id)
18590
+ break
18591
+ input_array = _pick_split_array(
18592
+ upstream_outputs,
18593
+ source=f"node '{upstream_id}'" if upstream_id else "no upstream edge",
18594
+ )
18595
+
18596
+ # 선언한 items 출력 포트를 실제로 채운다(선언==런타임). 값은 모든 아이템
18597
+ # 공통이라 루프 전에 한 번만 세팅한다. 예전엔 branch flow 에서
18598
+ # SplitNodeExecutor.execute 가 아예 호출되지 않아 items 가 늘 비었다.
18599
+ self.context.set_output(split_id, "items", input_array)
18341
18600
 
18342
18601
  if not input_array:
18343
18602
  self.context.log("info", f"SplitNode {split_id}: empty array, skipping branch", split_id)
@@ -18345,16 +18604,10 @@ class WorkflowJob:
18345
18604
  node_id=split_id,
18346
18605
  node_type="SplitNode",
18347
18606
  state=NodeState.COMPLETED,
18348
- outputs={"item": None, "index": 0, "total": 0},
18607
+ outputs={"item": None, "index": 0, "total": 0, "items": []},
18349
18608
  )
18350
18609
  return
18351
18610
 
18352
- # Get SplitNode config
18353
- config = dict(split_node.config)
18354
- parallel = config.get("parallel", False)
18355
- delay_ms = config.get("delay_ms", 0)
18356
- continue_on_error = config.get("continue_on_error", True)
18357
-
18358
18611
  total = len(input_array)
18359
18612
  collected_results: List[Any] = []
18360
18613
  errors: List[str] = []
@@ -18377,7 +18630,8 @@ class WorkflowJob:
18377
18630
  for coro in asyncio.as_completed(tasks):
18378
18631
  try:
18379
18632
  result = await coro
18380
- collected_results.append(result)
18633
+ if result is not _SKIP_BRANCH_ITEM:
18634
+ collected_results.append(result)
18381
18635
  except Exception as e:
18382
18636
  if continue_on_error:
18383
18637
  errors.append(str(e))
@@ -18395,7 +18649,8 @@ class WorkflowJob:
18395
18649
  index=idx,
18396
18650
  total=total,
18397
18651
  )
18398
- collected_results.append(result)
18652
+ if result is not _SKIP_BRANCH_ITEM:
18653
+ collected_results.append(result)
18399
18654
  except Exception as e:
18400
18655
  if continue_on_error:
18401
18656
  errors.append(str(e))
@@ -18416,7 +18671,7 @@ class WorkflowJob:
18416
18671
  node_id=split_id,
18417
18672
  node_type="SplitNode",
18418
18673
  state=NodeState.COMPLETED,
18419
- outputs={"item": input_array[-1] if input_array else None, "index": total - 1, "total": total},
18674
+ outputs={"item": input_array[-1] if input_array else None, "index": total - 1, "total": total, "items": input_array},
18420
18675
  duration_ms=duration_ms,
18421
18676
  )
18422
18677
 
@@ -18459,6 +18714,7 @@ class WorkflowJob:
18459
18714
  self.context.set_output(split_id, "total", total)
18460
18715
 
18461
18716
  result = item
18717
+ last_had_public = None # None = branch 노드 없음 / True|False = 마지막 노드 결과
18462
18718
 
18463
18719
  # Execute each branch node
18464
18720
  for node_id in branch_order:
@@ -18493,9 +18749,20 @@ class WorkflowJob:
18493
18749
  for port_name, value in outputs.items():
18494
18750
  self.context.set_output(node_id, port_name, value)
18495
18751
 
18496
- # Track last result
18497
- if outputs:
18498
- result = outputs.get("result") or outputs.get("value") or next(iter(outputs.values()), item)
18752
+ # Track last result — only from PUBLIC ports. Internal meta
18753
+ # (_throttle_stats 등)이 유일 출력일 때 그걸 결과로 집으면 Aggregate/Display
18754
+ # 내부 통계를 데이터로 렌더한다.
18755
+ public = _public_outputs(outputs) if outputs else {}
18756
+ last_had_public = bool(public)
18757
+ if public:
18758
+ result = public.get("result") or public.get("value") or next(iter(public.values()), item)
18759
+
18760
+ # 마지막 branch 노드가 공개 출력을 전혀 못 냈으면(예: ThrottleNode 가 이번 사이클
18761
+ # 을 throttling 중이라 내부 메타만 반환) 이 아이템은 기여할 실데이터가 없다 →
18762
+ # skip 하여 "실시간 체결가" 표에 가격 없는/이질적인 행이 끼지 않게 한다. branch
18763
+ # 노드가 아예 없으면(bare Split→Aggregate) item 을 그대로 수집한다(정상 패턴).
18764
+ if branch_order and last_had_public is False:
18765
+ return _SKIP_BRANCH_ITEM
18499
18766
 
18500
18767
  return result
18501
18768
 
@@ -1099,15 +1099,16 @@ class WorkflowResolver:
1099
1099
  # (1) 짝 AggregateNode 가 그래프상 도달 가능해야 한다. 없으면 _execute_split_branch
1100
1100
  # 가 aggregate_id=None 에서 즉시 return → branch 자체가 실행 안 됨(노드 pending
1101
1101
  # 유지, 하류 item 전부 공백). engine._find_split_aggregate_pairs 참조.
1102
- # (2) split 들어오는 상류 노드가 리스트를 출력해야 한다. 엔진은 분리 대상 배열을
1103
- # **오직 상류 노드 출력**(포트 symbols/values/array/data/items)에서만 읽는다.
1104
- # config `array` 필드는 **어느 경로에서도 읽히지 않는다**(SplitNodeExecutor.execute
1105
- # 의 config.get("array") 죽은 코드 — 실행 엔진이 그 executor 를 거치지 않고
1106
- # 상류 출력에서 배열을 직접 가져와 item set). 따라서 `array`/`items` 를 config
1107
- # 리터럴로 넣는 저작 패턴은 검증만 통과하고 런타임엔 0개를 낸다.
1108
- # 하나라도 빠지면 하류 `{{ nodes.<split>.item }}` 조용히 비어 count:0 쓰레기
1109
- # 결과가 된다(단일 심볼에 SplitNode 잘못 붙이는 것이 전형적 저작 결함). 정적으로
1110
- # 잡아 AI self-correct 루프가 dry_run 전에 보게 한다.
1102
+ # (2) 분리 대상 배열의 소스가 있어야 한다. 엔진(_execute_split_branch)은 배열을
1103
+ # (a) 명시적 `array` 바인딩({{ }} 표현식 또는 리터럴 리스트)에서 **1순위**로,
1104
+ # 없으면 (b) 상류 노드의 리스트 출력(KNOWN 포트 symbols/values/array/data/
1105
+ # items/held_symbols/positions/open_orders)에서 결정적으로 읽는다.
1106
+ # (2026-07-14 이전엔 config `array` 어느 경로에서도 읽혔고 상류의 "첫
1107
+ # 리스트"를 dict 순서대로 집었다 낡은 서술은 폐기.) 상류가 다중 배열
1108
+ # 포트(계좌 노드)를 노출하는데 `array` 바인딩이 없으면 엔진이 모호하다며
1109
+ # 런타임에 raise 하므로, 경우 명시 바인딩이 필수다.
1110
+ # (1)이 빠지면 branch 자체가 실행 되고, (2)가 빠지면 하류 `{{ nodes.<split>.item }}`
1111
+ # 이 비어 count:0 결과가 된다. 정적으로 잡아 AI self-correct 루프가 dry_run 전에 보게 한다.
1111
1112
  # 오탐(최대 리스크) 최소화: (2)는 상류 중 하나라도 리스트를 낼 "가능성"이 있으면 통과.
1112
1113
  # 가능성 = (a) 상류가 CodeNode(동적 출력), (b) 상류 스키마 미상(커뮤니티/제네릭),
1113
1114
  # (c) 상류 출력 포트 타입 중 하나라도 확정 스칼라/시그널이 아님.
@@ -1120,8 +1121,8 @@ class WorkflowResolver:
1120
1121
  def _validate_split_nodes(self, workflow, registry, result: ValidationResult) -> None:
1121
1122
  """SplitNode 가 실 엔진에서 동작 가능하게 배선됐는지 정적 검증(오탐 보수적).
1122
1123
 
1123
- 엔진 동작(프로브 확정): SplitNode 는 (1) 짝 AggregateNode 가 도달 가능하고
1124
- (2) 상류가 리스트를 출력해야만 branch 실행한다. config `array` 안 읽힌다.
1124
+ 엔진 동작: SplitNode 는 (1) 짝 AggregateNode 가 도달 가능해야 branch 를 실행하고,
1125
+ (2) 분리 배열은 명시 `array` 바인딩(1순위) 또는 상류 리스트 출력에서 읽는다.
1125
1126
  """
1126
1127
  split_nodes = [
1127
1128
  n for n in workflow.nodes
@@ -1166,6 +1167,27 @@ class WorkflowResolver:
1166
1167
  queue.append(nxt)
1167
1168
  return False
1168
1169
 
1170
+ # 엔진 _pick_split_array 가 상류 dict 에서 배열로 인정하는 KNOWN 포트 이름. 상류가
1171
+ # 이 중 **2개 이상**을 출력으로 선언하면(계좌: held_symbols/positions/open_orders)
1172
+ # 엔진은 array 바인딩 없이는 모호하다며 런타임 raise 한다 → 정적으로 미리 막는다.
1173
+ _known_array_ports = {
1174
+ "array", "symbols", "values", "data", "items",
1175
+ "held_symbols", "positions", "open_orders",
1176
+ }
1177
+
1178
+ def _count_array_ports(src_type: Optional[str]) -> int:
1179
+ if not src_type:
1180
+ return 0
1181
+ schema = registry.get_schema(src_type)
1182
+ if schema is None:
1183
+ return 0
1184
+ n = 0
1185
+ for out in (schema.outputs or []):
1186
+ name = out.get("name") if isinstance(out, dict) else getattr(out, "name", None)
1187
+ if name in _known_array_ports:
1188
+ n += 1
1189
+ return n
1190
+
1169
1191
  def _may_produce_list(src_type: Optional[str]) -> bool:
1170
1192
  # 동적/미상 출력은 리스트 가능 → 보수적으로 통과.
1171
1193
  if not src_type or src_type == "CodeNode":
@@ -1206,33 +1228,137 @@ class WorkflowResolver:
1206
1228
  )
1207
1229
  )
1208
1230
  continue
1209
- # (2) 상류 리스트 소스 — config `array` 엔진이 읽으므로 소스로 인정 안 함.
1231
+ # (2) 배열 소스 — 아래 하나라도 있으면 통과:
1232
+ # (a) 명시적 `array` 바인딩(엔진이 이제 1순위로 읽음: {{ }} 표현식 또는
1233
+ # 리터럴 리스트). 상류가 다중 배열 포트(계좌: held_symbols/positions/
1234
+ # open_orders)일 때는 이 바인딩이 필수다 — 없으면 엔진이 모호하다며
1235
+ # 런타임에 raise 한다.
1236
+ # (b) 상류 노드가 리스트를 낼 가능성(단일 배열 소스면 바인딩 없이도 동작).
1237
+ array_cfg = node.get("array")
1238
+ has_array_binding = (
1239
+ (isinstance(array_cfg, str) and "{{" in array_cfg)
1240
+ or (isinstance(array_cfg, list) and len(array_cfg) > 0)
1241
+ )
1242
+ if has_array_binding:
1243
+ continue
1210
1244
  srcs = incoming.get(sid, [])
1245
+ # (2') 다중 배열 상류(계좌 등) — 바인딩이 없으면 엔진이 어느 배열을 분리할지
1246
+ # 모호하다며 런타임 raise 한다. 그 raise 를 **빌드 시점 error 로 승격**해 챗봇
1247
+ # self-correct 루프가 실행 전에 잡게 한다. (silent misroute 방지 규율.)
1248
+ ambiguous = [
1249
+ s for s in srcs if _count_array_ports(node_type_by_id.get(s)) >= 2
1250
+ ]
1251
+ if ambiguous:
1252
+ amb_type = node_type_by_id.get(ambiguous[0]) or "?"
1253
+ result.add(
1254
+ build_error(
1255
+ ErrorCode.MISSING_REQUIRED_FIELD,
1256
+ (
1257
+ f"SplitNode '{sid}' 의 상류 '{ambiguous[0]}'({amb_type})는 여러 배열을 "
1258
+ f"출력한다(held_symbols/positions/open_orders 등). 엔진은 어느 배열을 "
1259
+ f"분리할지 몰라 런타임에 실패한다 — `array` 를 명시 바인딩해 하나를 "
1260
+ f"골라야 한다."
1261
+ ),
1262
+ location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path="array"),
1263
+ suggestion=(
1264
+ "Bind `array` explicitly to the array you want to split, e.g. "
1265
+ "array: '{{ nodes." + str(ambiguous[0]) + ".held_symbols }}' "
1266
+ "(보유종목) 또는 '{{ nodes." + str(ambiguous[0]) + ".positions }}' (포지션)."
1267
+ ),
1268
+ )
1269
+ )
1270
+ continue
1211
1271
  if srcs and any(_may_produce_list(node_type_by_id.get(s)) for s in srcs):
1212
1272
  continue
1213
1273
  result.add(
1214
1274
  build_error(
1215
1275
  ErrorCode.MISSING_REQUIRED_FIELD,
1216
1276
  (
1217
- f"SplitNode '{sid}' has no upstream node that outputs a list. The "
1218
- f"engine reads the array to split ONLY from an upstream node's list "
1219
- f"output (ports symbols/values/array/data/items) the `array` "
1220
- f"config field is NOT read at runtime. It will emit zero items and "
1221
- f"every downstream `{{{{ nodes.{sid}.item }}}}` resolves empty "
1222
- f"(silent count:0 result)."
1277
+ f"SplitNode '{sid}' has no array source. The engine reads the array "
1278
+ f"to split from (1) an explicit `array` binding ({{{{ ... }}}} or a "
1279
+ f"literal list), or (2) an upstream node's list output (ports "
1280
+ f"symbols/values/held_symbols/positions/…). This SplitNode has "
1281
+ f"neither, so it emits zero items and every downstream "
1282
+ f"`{{{{ nodes.{sid}.item }}}}` resolves empty (silent count:0 result)."
1223
1283
  ),
1224
1284
  location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path="array"),
1225
1285
  suggestion=(
1226
- "Connect an edge from an upstream node that OUTPUTS a list "
1227
- "(WatchlistNode/MarketUniverseNode symbols, AccountNode "
1228
- "positions, ConditionNode values, or a CodeNode returning a list). "
1229
- "Do NOT put the list in an `array`/`items` config field the engine "
1230
- "ignores it. For a single known symbol, DELETE SplitNode and bind "
1231
- "that symbol directly on the downstream node."
1286
+ "Either bind `array` explicitly (e.g. array: "
1287
+ "'{{ nodes.<account>.held_symbols }}') REQUIRED when the upstream "
1288
+ "exposes several arrays (held_symbols/positions/open_orders), or "
1289
+ "connect an edge from a node that OUTPUTS a single list "
1290
+ "(WatchlistNode/MarketUniverseNode symbols, ConditionNode values, "
1291
+ "or a CodeNode returning a list). For a single known symbol, DELETE "
1292
+ "SplitNode and bind that symbol directly on the downstream node."
1232
1293
  ),
1233
1294
  )
1234
1295
  )
1235
1296
 
1297
+ # (3) 하류 realtime market 노드의 `symbol` 미바인딩 — Split fan-out 이 장식이 된다.
1298
+ # 선언된 `symbol` 이 없으면 엔진(_resolve_symbols)은 상류 계좌 held_symbols
1299
+ # 전체로 폴백해 **모든 branch 가 전 종목을 중복 구독**한다(per-branch 구독 실패).
1300
+ # 이건 결함1 엔진 수정만으로는 못 잡는다(사용자가 원한 "종목별 실시간 행"이
1301
+ # 안 됨). 2026-07-14. 코퍼스(split→realtime market 예제) 오탐 0 재검증.
1302
+ split_ids: Set[str] = {n.get("id") for n in split_nodes}
1303
+
1304
+ def _has_split_ancestor(start: Optional[str]) -> bool:
1305
+ if not start:
1306
+ return False
1307
+ seen: Set[str] = set()
1308
+ queue: List[str] = list(incoming.get(start, []))
1309
+ while queue:
1310
+ cur = queue.pop(0)
1311
+ if cur in seen:
1312
+ continue
1313
+ seen.add(cur)
1314
+ if cur in split_ids:
1315
+ return True
1316
+ queue.extend(incoming.get(cur, []))
1317
+ return False
1318
+
1319
+ _RT_MARKET_TYPES = {
1320
+ "RealMarketDataNode", "OverseasStockRealMarketDataNode",
1321
+ "KoreaStockRealMarketDataNode", "OverseasFuturesRealMarketDataNode",
1322
+ }
1323
+ # port-qualified 엣지(예: 'rm.symbol')로 symbol 을 배선한 경우는 오탐이 아니다
1324
+ # (엔진이 wired 입력 포트도 읽음) → 그런 타겟이 있으면 제외.
1325
+ symbol_wired: Set[str] = set()
1326
+ for edge in workflow.edges:
1327
+ to_raw = getattr(edge, "to_node", "") or ""
1328
+ if "." in to_raw and to_raw.split(".", 1)[1] == "symbol":
1329
+ symbol_wired.add(to_raw.split(".")[0])
1330
+
1331
+ for node in workflow.nodes:
1332
+ if not isinstance(node, dict) or node.get("type") not in _RT_MARKET_TYPES:
1333
+ continue
1334
+ rid = node.get("id")
1335
+ sym = node.get("symbol")
1336
+ symbol_bound = (
1337
+ (isinstance(sym, str) and "{{" in sym)
1338
+ or (isinstance(sym, dict) and bool(sym.get("symbol")))
1339
+ )
1340
+ if symbol_bound or rid in symbol_wired:
1341
+ continue
1342
+ if _has_split_ancestor(rid):
1343
+ result.add(
1344
+ build_error(
1345
+ ErrorCode.MISSING_REQUIRED_FIELD,
1346
+ (
1347
+ f"RealMarketDataNode '{rid}' 는 상류 SplitNode 의 per-item fan-out "
1348
+ f"아래 있는데 `symbol` 이 바인딩되지 않았다. 엔진은 선언된 `symbol` 이 "
1349
+ f"없으면 상류 계좌의 held_symbols 전체로 폴백해 **모든 branch 가 전 종목을 "
1350
+ f"중복 구독**한다 — Split fan-out 이 무의미해지고 '종목별 실시간 행'이 안 된다."
1351
+ ),
1352
+ location=ErrorLocation(
1353
+ node_id=rid, node_type=node.get("type"), field_path="symbol"
1354
+ ),
1355
+ suggestion=(
1356
+ "`symbol` 을 각 branch 의 항목에 바인딩하라: "
1357
+ "symbol: '{{ nodes.<split>.item }}' (per-branch 단일 종목)."
1358
+ ),
1359
+ )
1360
+ )
1361
+
1236
1362
  def _validate_ai_agent_nodes(self, workflow, result: ValidationResult) -> None:
1237
1363
  """AIAgentNode 는 ai_model 엣지로 LLMModelNode 가 연결돼야 한다.
1238
1364
 
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.28.0"
8
+ version = "1.29.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -29,7 +29,7 @@ lxml = "^6.0.2"
29
29
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
30
30
  aiosqlite = "^0.20.0"
31
31
  litellm = ">=1.40.0"
32
- programgarden-core = "^1.20.0"
32
+ programgarden-core = "^1.21.0"
33
33
  programgarden-finance = "^1.6.14"
34
34
  programgarden-community = "^1.13.11"
35
35
 
File without changes