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.
- {programgarden-1.28.0 → programgarden-1.29.0}/PKG-INFO +2 -2
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/__init__.py +1 -1
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/executor.py +378 -111
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resolver.py +150 -24
- {programgarden-1.28.0 → programgarden-1.29.0}/pyproject.toml +2 -2
- {programgarden-1.28.0 → programgarden-1.29.0}/README.md +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/client.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/context.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.28.0 → programgarden-1.29.0}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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.
|
|
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)
|
|
@@ -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)
|
|
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
|
-
#
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
#
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
#
|
|
953
|
-
if
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
#
|
|
985
|
-
|
|
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":
|
|
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"]}
|
|
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":
|
|
5366
|
-
"
|
|
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
|
-
#
|
|
5835
|
-
#
|
|
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"]}
|
|
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
|
-
|
|
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
|
|
6254
|
-
#
|
|
6255
|
-
exchange_code = self._get_stock_exchange_code(
|
|
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
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
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
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
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
|
-
"""
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
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
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
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
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
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
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
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
|
-
#
|
|
6937
|
+
|
|
6938
|
+
# 2. WatchlistNode에서 받기 — 포트 경로
|
|
6716
6939
|
if watchlist_output and watchlist_output.get("symbols"):
|
|
6717
6940
|
return watchlist_output["symbols"]
|
|
6718
|
-
|
|
6719
|
-
#
|
|
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
|
|
18326
|
-
|
|
18327
|
-
|
|
18328
|
-
|
|
18329
|
-
|
|
18330
|
-
|
|
18331
|
-
|
|
18332
|
-
|
|
18333
|
-
|
|
18334
|
-
|
|
18335
|
-
|
|
18336
|
-
|
|
18337
|
-
|
|
18338
|
-
|
|
18339
|
-
|
|
18340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18498
|
-
|
|
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)
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
#
|
|
1106
|
-
#
|
|
1107
|
-
#
|
|
1108
|
-
#
|
|
1109
|
-
#
|
|
1110
|
-
#
|
|
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
|
-
엔진
|
|
1124
|
-
(2)
|
|
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)
|
|
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
|
|
1218
|
-
f"
|
|
1219
|
-
f"
|
|
1220
|
-
f"
|
|
1221
|
-
f"
|
|
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
|
-
"
|
|
1227
|
-
"
|
|
1228
|
-
"
|
|
1229
|
-
"
|
|
1230
|
-
"
|
|
1231
|
-
"
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.28.0 → programgarden-1.29.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.28.0 → programgarden-1.29.0}/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
|