programgarden 1.33.2__tar.gz → 1.33.4__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.33.2 → programgarden-1.33.4}/PKG-INFO +2 -2
  2. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/executor.py +363 -17
  3. {programgarden-1.33.2 → programgarden-1.33.4}/pyproject.toml +2 -2
  4. {programgarden-1.33.2 → programgarden-1.33.4}/README.md +0 -0
  5. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/__init__.py +0 -0
  6. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/client.py +0 -0
  8. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/code_worker.py +0 -0
  9. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/context.py +0 -0
  10. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resolver.py +0 -0
  24. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.33.2 → programgarden-1.33.4}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.33.2
3
+ Version: 1.33.4
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -17,7 +17,7 @@ Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
18
  Requires-Dist: programgarden-community (>=1.15.0,<2.0.0)
19
19
  Requires-Dist: programgarden-core (>=1.25.0,<2.0.0)
20
- Requires-Dist: programgarden-finance (>=1.9.1,<2.0.0)
20
+ Requires-Dist: programgarden-finance (>=1.9.3,<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)
23
23
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -778,6 +778,260 @@ def evaluate_all_bindings(
778
778
  return {k: evaluate_value(v) for k, v in config.items()}
779
779
 
780
780
 
781
+ # ---------------------------------------------------------------------------
782
+ # D2 (AI 모델 벤치마크 2026-09-06): "No symbols provided" 3분류
783
+ #
784
+ # 사이징/과거시세/현재가 노드가 빈 종목 목록을 받는 원인은 세 가지인데, 종전에는 전부
785
+ # 같은 warning("No symbols provided …")으로 찍혔다. 챗봇 저장 게이트(pg-ai
786
+ # `_detect_input_warnings`)는 그 문구를 "설계 결함" 으로 읽어 저장을 막으므로, 조건을
787
+ # 올바르게 배선한 워크플로우가 **모의 실행 표본에서 오늘 조건을 통과한 종목이 0** 이라는
788
+ # 이유만으로 저장되지 못했다(벤치 본선: 모델 무관 최대 실패 원인, 40여 건 중 대부분이
789
+ # `symbols: {{ nodes.rsi.passed_symbols }}` / `{{ nodes.filter.symbols }}` 정상 배선).
790
+ #
791
+ # no_signal — 원본 설정에 바인딩(또는 입력 포트)이 있고 상류가 이번 실행에서 빈 목록을
792
+ # 냈다. 정상 런타임(오늘 신호 없음). info 로만 남기고 경고를 찍지 않는다.
793
+ # unresolved — 바인딩 표현식이 리터럴로 남았다(없는 노드/포트, 반복 밖의 `{{ item }}`).
794
+ # unbound — symbols/symbol 자체가 없다(종목 소스 미연결).
795
+ # 뒤의 둘은 설계 결함이라 종전처럼 warning("No symbols provided …") 을 유지한다 — 게이트가
796
+ # 계속 잡아야 하는 케이스다.
797
+ # ---------------------------------------------------------------------------
798
+ EMPTY_SYMBOLS_NO_SIGNAL = "no_signal"
799
+ EMPTY_SYMBOLS_UNRESOLVED = "unresolved"
800
+ EMPTY_SYMBOLS_UNBOUND = "unbound"
801
+ EMPTY_SYMBOLS_UPSTREAM_FAILED = "upstream_failed" # 참조한 노드가 error/fetch_failed 를 냈다
802
+
803
+ _EMPTY_VALUES = (None, "", [], {}, ())
804
+
805
+
806
+ def _raw_node_config(node_id: str, workflow: Any, fallback: Dict[str, Any]) -> Dict[str, Any]:
807
+ """`ResolvedWorkflow.nodes[node_id].config` (평가 전 원본). 없으면 fallback."""
808
+ try:
809
+ nodes = getattr(workflow, "nodes", None)
810
+ node = nodes.get(node_id) if isinstance(nodes, dict) else None
811
+ except Exception:
812
+ node = None
813
+ cfg = getattr(node, "config", None)
814
+ return cfg if isinstance(cfg, dict) else fallback
815
+
816
+
817
+ def _input_namespace(context: "ExecutionContext", node_id: str) -> Dict[str, Any]:
818
+ """`_input_<node_id>` 의사 노드(엣지로 들어온 상류 출력 전체). 없으면 {}."""
819
+ try:
820
+ outputs = context.get_all_outputs(f"_input_{node_id}")
821
+ except Exception:
822
+ return {}
823
+ return outputs if isinstance(outputs, dict) else {}
824
+
825
+
826
+ def _contains_expression(value: Any) -> bool:
827
+ if isinstance(value, str):
828
+ return "{{" in value
829
+ if isinstance(value, dict):
830
+ return any(_contains_expression(v) for v in value.values())
831
+ if isinstance(value, list):
832
+ return any(_contains_expression(v) for v in value)
833
+ return False
834
+
835
+
836
+ def _first_expression(value: Any) -> Optional[str]:
837
+ if isinstance(value, str):
838
+ return value if "{{" in value else None
839
+ if isinstance(value, dict):
840
+ for v in value.values():
841
+ found = _first_expression(v)
842
+ if found:
843
+ return found
844
+ if isinstance(value, list):
845
+ for v in value:
846
+ found = _first_expression(v)
847
+ if found:
848
+ return found
849
+ return None
850
+
851
+
852
+ def _referenced_node_failure(expr: Optional[str], context: "ExecutionContext") -> Optional[str]:
853
+ """`{{ nodes.<id>.<port> }}` 가 가리키는 노드의 FULL 출력에 실패 신호(error /
854
+ reason=fetch_failed / _partial_failure)가 있으면 그 사유, 없으면 None.
855
+
856
+ MarketData/HistoricalData 는 조회 실패를 raise 하지 않고 ``{"values": [], "error": …}``
857
+ 로 돌려주므로, 그 ``values`` 를 symbols 로 묶은 하류는 빈 목록만 보고 "오늘 신호 없음"
858
+ 으로 오판할 수 있다 — 여기서 참조 노드 자체를 본다.
859
+ """
860
+ if not isinstance(expr, str) or context is None:
861
+ return None
862
+ import re as _re
863
+
864
+ m = _re.search(r"nodes\.([A-Za-z0-9_\-]+)", expr)
865
+ if not m:
866
+ return None
867
+ try:
868
+ outputs = context.get_all_outputs(m.group(1))
869
+ except Exception:
870
+ return None
871
+ if not isinstance(outputs, dict) or not outputs:
872
+ return None
873
+ return NewOrderNodeExecutor._extract_upstream_error(outputs)
874
+
875
+
876
+ # 심볼을 나르는 포트 — `{{ item }}` 이 반복 밖에서 리터럴로 남았을 때 "상류 배열이 비어
877
+ # 반복이 안 일어났다" 를 판정할 축. 메인 루프의 auto-iterate 소스 우선순위(명시 from_port
878
+ # > symbols > 첫 출력)와 맞춘다: ConditionNode 는 0건 통과일 때도 ``symbols``(평가 대상 전체)·
879
+ # ``failed_symbols``·``symbol_results`` 가 **비어 있지 않으므로**, 모든 리스트 포트가 비어야
880
+ # 한다는 규칙은 D2 가 없애려던 바로 그 케이스(조건 미통과 → `symbol: {{ item }}` 경고)를
881
+ # 남긴다. passed_symbols 가 있으면 그것만, 없으면 symbols, 둘 다 없으면 전체 리스트 포트.
882
+ _ITERATION_SYMBOL_PORTS = ("passed_symbols", "symbols", "items", "values")
883
+
884
+
885
+ def _upstream_array_is_empty(inputs: Dict[str, Any]) -> Optional[bool]:
886
+ """True = 반복 소스 배열이 비어 있다(no_signal), False = 배열이 있는데 반복이 안 걸렸다
887
+ (설계 결함), None = 리스트 포트가 하나도 없다."""
888
+ for port in _ITERATION_SYMBOL_PORTS:
889
+ if port in inputs and isinstance(inputs[port], list):
890
+ return len(inputs[port]) == 0
891
+ list_ports = [v for k, v in inputs.items() if isinstance(v, list) and not str(k).startswith("_")]
892
+ if not list_ports:
893
+ return None
894
+ return all(len(v) == 0 for v in list_ports)
895
+
896
+
897
+ def classify_empty_symbol_source(
898
+ node_id: str,
899
+ evaluated_config: Dict[str, Any],
900
+ context: "ExecutionContext",
901
+ workflow: Any = None,
902
+ keys: Tuple[str, ...] = ("symbols", "symbol"),
903
+ ) -> Tuple[str, str]:
904
+ """빈 종목 입력의 원인을 (kind, detail) 로 돌려준다.
905
+
906
+ kind ∈ {EMPTY_SYMBOLS_NO_SIGNAL, EMPTY_SYMBOLS_UNRESOLVED, EMPTY_SYMBOLS_UNBOUND}.
907
+ detail 은 사람이 읽는 한 문장(영문 — 챗봇 fix_hint 로 그대로 실린다).
908
+
909
+ 판정 순서:
910
+ 1. 평가된 값에 `{{ … }}` 가 리터럴로 남아 있다 → 미해석.
911
+ 단 `{{ item }}`/`{{ index }}` 인데 반복 컨텍스트가 없으면, 상류 배열이 비어
912
+ 반복이 안 일어난 것(no_signal)과 배열 소스 없이 item 바인딩을 쓴 것(unresolved)을
913
+ `_input_<id>` 의 리스트 포트로 가른다.
914
+ 2. 원본 설정에 바인딩/리터럴이 있었거나 입력 포트로 값이 들어왔다 → 상류가 빈 목록을
915
+ 낸 것(no_signal). 원본이 표현식 없는 리터럴 목록인데 정규화 결과가 비었다면 항목
916
+ 모양이 틀린 것(unbound 로 분류하되 detail 로 알린다).
917
+ 3. 그 외 → 종목 소스 미연결(unbound).
918
+ """
919
+ import re as _re
920
+
921
+ item_re = _re.compile(r"\{\{\s*(item|index)\b")
922
+ raw = _raw_node_config(node_id, workflow, evaluated_config)
923
+ inputs = _input_namespace(context, node_id)
924
+ iterating = getattr(context, "_iteration_item", None) is not None
925
+
926
+ # 1) 미해석 표현식
927
+ for key in keys:
928
+ value = evaluated_config.get(key)
929
+ expr = _first_expression(value)
930
+ if not expr:
931
+ continue
932
+ if item_re.search(expr) and not iterating:
933
+ empty = _upstream_array_is_empty(inputs)
934
+ if empty is True:
935
+ return (
936
+ EMPTY_SYMBOLS_NO_SIGNAL,
937
+ f"`{key}: {expr}` — the upstream symbol array is empty in this run "
938
+ "(no symbol passed / nothing to iterate), so this node was not iterated (no signal)",
939
+ )
940
+ return (
941
+ EMPTY_SYMBOLS_UNRESOLVED,
942
+ f"`{key}: {expr}` is an item binding but this node is not being iterated — "
943
+ "`{{ item }}` only resolves when this node is auto-iterated over an upstream "
944
+ "array (WatchlistNode.symbols / ConditionNode.passed_symbols / SymbolFilterNode.symbols); "
945
+ "inside a SplitNode branch use `{{ nodes.<split>.item }}` instead",
946
+ )
947
+ return (
948
+ EMPTY_SYMBOLS_UNRESOLVED,
949
+ f"`{key}: {expr}` did not resolve — check the node id and output port "
950
+ "(e.g. `{{ nodes.<condition>.passed_symbols }}` / `{{ nodes.<filter>.symbols }}`)",
951
+ )
952
+
953
+ # 2) 바인딩/입력 포트는 있는데 결과가 빈 목록
954
+ bound_key = None
955
+ for key in keys:
956
+ if raw.get(key) not in _EMPTY_VALUES:
957
+ bound_key = key
958
+ break
959
+ port_key = next((k for k in keys if k in inputs), None)
960
+
961
+ if bound_key is not None:
962
+ raw_value = raw[bound_key]
963
+ if _contains_expression(raw_value):
964
+ expr = _first_expression(raw_value)
965
+ resolved = evaluated_config.get(bound_key)
966
+ # 참조한 노드 자체가 실패했다(MarketData/HistoricalData 의 `{"values": [], "error": …}`,
967
+ # reason=fetch_failed, _partial_failure) → 빈 목록은 신호 없음이 아니라 파이프라인 고장.
968
+ failure = _referenced_node_failure(expr, context)
969
+ if failure:
970
+ return (
971
+ EMPTY_SYMBOLS_UPSTREAM_FAILED,
972
+ f"upstream node referenced by `{bound_key}: {expr}` failed: {failure}",
973
+ )
974
+ # 표현식이 **빈 목록이 아니라 None** 으로 풀렸다 = 참조한 노드/포트가 없다
975
+ # (평가기는 없는 포트를 None 으로 관대하게 푼다). 실제 종목 소스(ConditionNode.
976
+ # passed_symbols / SymbolFilterNode.symbols …)는 비어도 항상 [] 를 낸다.
977
+ if resolved is None and bound_key not in inputs:
978
+ return (
979
+ EMPTY_SYMBOLS_UNRESOLVED,
980
+ f"`{bound_key}: {expr}` resolved to nothing — the referenced node id or "
981
+ "output port does not exist (check the spelling; e.g. "
982
+ "`{{ nodes.<condition>.passed_symbols }}` / `{{ nodes.<filter>.symbols }}`)",
983
+ )
984
+ # 스칼라(bool/int/str)로 풀렸다 = 종목 목록이 아닌 포트를 묶었다(`result` /
985
+ # `is_condition_met` / `count`). 매 실행 빈 목록이 되는 영구 결함이라 warning.
986
+ if resolved is not None and not isinstance(resolved, (list, dict)):
987
+ return (
988
+ EMPTY_SYMBOLS_UNRESOLVED,
989
+ f"`{bound_key}: {expr}` resolved to a {type(resolved).__name__} "
990
+ f"({str(resolved)[:40]!r}), not a symbol list — bind a list port "
991
+ "(`passed_symbols` / `symbols`), not `result` / `is_condition_met` / `count`",
992
+ )
993
+ return (
994
+ EMPTY_SYMBOLS_NO_SIGNAL,
995
+ f"upstream symbol source `{bound_key}: {expr}` "
996
+ "produced no symbols in this run (no signal)",
997
+ )
998
+ if isinstance(raw_value, (list, dict)):
999
+ return (
1000
+ EMPTY_SYMBOLS_UNBOUND,
1001
+ f"`{bound_key}` is a literal list but none of its entries is a usable "
1002
+ "{symbol, exchange} object or symbol string",
1003
+ )
1004
+ return (
1005
+ EMPTY_SYMBOLS_NO_SIGNAL,
1006
+ f"symbol source `{bound_key}` resolved to nothing in this run (no signal)",
1007
+ )
1008
+ if port_key is not None:
1009
+ port_value = inputs.get(port_key)
1010
+ # 엣지로 들어온 포트가 **비어 있지 않은데** 이 노드가 빈 목록을 봤다 = 이 노드는
1011
+ # 입력 포트를 읽지 않는다(사이징은 evaluated config 만 본다). 바인딩을 명시해야
1012
+ # 하는 설계 결함이지 "오늘 신호 없음" 이 아니다.
1013
+ if port_value not in _EMPTY_VALUES:
1014
+ return (
1015
+ EMPTY_SYMBOLS_UNBOUND,
1016
+ f"the upstream node emits a non-empty `{port_key}` list but this node has no "
1017
+ f"`{port_key}` binding to consume it — bind `{port_key}: "
1018
+ "{{ nodes.<upstream>." + port_key + " }}` explicitly",
1019
+ )
1020
+ return (
1021
+ EMPTY_SYMBOLS_NO_SIGNAL,
1022
+ f"the upstream node wired into this node emitted an empty `{port_key}` list "
1023
+ "in this run (no signal)",
1024
+ )
1025
+
1026
+ # 3) 소스 없음
1027
+ return (
1028
+ EMPTY_SYMBOLS_UNBOUND,
1029
+ "no symbol source is configured — bind `symbols` to an upstream list "
1030
+ "(`{{ nodes.<condition>.passed_symbols }}` / `{{ nodes.<filter>.symbols }}` / "
1031
+ "`{{ nodes.<watchlist>.symbols }}`) or `symbol` to `{{ item }}` inside a per-symbol loop",
1032
+ )
1033
+
1034
+
781
1035
  class GenericNodeExecutor(NodeExecutorBase):
782
1036
  """
783
1037
  범용 노드 실행기 (커뮤니티 노드 및 execute() 메서드가 있는 노드용)
@@ -10275,7 +10529,18 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10275
10529
  return _df.apply_override(fixture, override)
10276
10530
 
10277
10531
  if not symbols:
10278
- error_msg = "symbols 필드가 필수입니다. 종목을 직접 입력하거나 WatchlistNode를 연결하세요."
10532
+ # D2: 상류(조건/필터)가 이번 실행에서 빈 목록을 낸 정상 케이스는 error 없이 빈
10533
+ # values 로 흘린다(오늘 조회할 종목 없음). 소스 미연결/미해석만 종전 error.
10534
+ kind, detail = classify_empty_symbol_source(
10535
+ node_id, config, context, kwargs.get("workflow"), keys=("symbol", "symbols")
10536
+ )
10537
+ if kind == EMPTY_SYMBOLS_NO_SIGNAL:
10538
+ context.log("info", f"No symbols to quote in this run — {detail}", node_id)
10539
+ return {"values": []}
10540
+ error_msg = (
10541
+ "symbols 필드가 필수입니다. 종목을 직접 입력하거나 WatchlistNode를 연결하세요. "
10542
+ f"({detail})"
10543
+ )
10279
10544
  context.log("error", error_msg, node_id)
10280
10545
  return {"error": error_msg, "values": []}
10281
10546
 
@@ -10340,6 +10605,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10340
10605
  values = []
10341
10606
 
10342
10607
  for symbol_entry in symbols:
10608
+ exchange, symbol = "", "" # handler-safe defaults: the except below must never touch an unassigned name (UnboundLocalError masked the real cause)
10343
10609
  try:
10344
10610
  # 거래소와 심볼 추출
10345
10611
  exchange = symbol_entry.get("exchange", "NASDAQ")
@@ -10414,7 +10680,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10414
10680
  context.log("warning", f"No data for {exchange}:{symbol}", node_id)
10415
10681
 
10416
10682
  except Exception as e:
10417
- context.log("warning", f"Failed to fetch {exchange}:{symbol}: {e}", node_id)
10683
+ context.log("warning", f"Failed to fetch {exchange}:{symbol or symbol_entry!r}: {e}", node_id)
10418
10684
  continue
10419
10685
 
10420
10686
  return {"values": values}
@@ -10459,6 +10725,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10459
10725
  values = []
10460
10726
 
10461
10727
  for symbol_entry in symbols:
10728
+ exchange, symbol = "", "" # handler-safe defaults: the except below must never touch an unassigned name (UnboundLocalError masked the real cause)
10462
10729
  try:
10463
10730
  # 해외선물은 exchange 대신 symbol만 사용 (예: "GCGF25", "CLH25")
10464
10731
  exchange = symbol_entry.get("exchange", "CME")
@@ -10499,7 +10766,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10499
10766
  context.log("warning", f"No data for {exchange}:{symbol}", node_id)
10500
10767
 
10501
10768
  except Exception as e:
10502
- context.log("warning", f"Failed to fetch {exchange}:{symbol}: {e}", node_id)
10769
+ context.log("warning", f"Failed to fetch {exchange}:{symbol or symbol_entry!r}: {e}", node_id)
10503
10770
  continue
10504
10771
 
10505
10772
  return {"values": values}
@@ -10544,6 +10811,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10544
10811
  values = []
10545
10812
 
10546
10813
  for symbol_entry in symbols:
10814
+ symbol = "" # handler-safe default: the except below must never touch an unassigned name (UnboundLocalError masked the real cause)
10547
10815
  try:
10548
10816
  symbol = symbol_entry.get("symbol", "")
10549
10817
  if not symbol:
@@ -10583,7 +10851,7 @@ class MarketDataNodeExecutor(NodeExecutorBase):
10583
10851
  context.log("warning", f"No data for KRX:{symbol}", node_id)
10584
10852
 
10585
10853
  except Exception as e:
10586
- context.log("warning", f"Failed to fetch KRX:{symbol}: {e}", node_id)
10854
+ context.log("warning", f"Failed to fetch KRX:{symbol or symbol_entry!r}: {e}", node_id)
10587
10855
  continue
10588
10856
 
10589
10857
  return {"values": values}
@@ -10644,7 +10912,17 @@ class FundamentalNodeExecutor(NodeExecutorBase):
10644
10912
  symbols = [config_symbol]
10645
10913
 
10646
10914
  if not symbols:
10647
- error_msg = "symbols 필드가 필수입니다. 종목을 직접 입력하거나 WatchlistNode를 연결하세요."
10915
+ # D2: MarketDataNodeExecutor 와 같은 3분류 — 정상 no-signal 은 error 없이 빈 values.
10916
+ kind, detail = classify_empty_symbol_source(
10917
+ node_id, config, context, kwargs.get("workflow"), keys=("symbol", "symbols")
10918
+ )
10919
+ if kind == EMPTY_SYMBOLS_NO_SIGNAL:
10920
+ context.log("info", f"No symbols to look up in this run — {detail}", node_id)
10921
+ return {"values": []}
10922
+ error_msg = (
10923
+ "symbols 필드가 필수입니다. 종목을 직접 입력하거나 WatchlistNode를 연결하세요. "
10924
+ f"({detail})"
10925
+ )
10648
10926
  context.log("error", error_msg, node_id)
10649
10927
  return {"error": error_msg, "values": []}
10650
10928
 
@@ -10690,6 +10968,7 @@ class FundamentalNodeExecutor(NodeExecutorBase):
10690
10968
  values = []
10691
10969
 
10692
10970
  for symbol_entry in symbols:
10971
+ exchange, symbol = "", "" # handler-safe defaults: the except below must never touch an unassigned name (UnboundLocalError masked the real cause)
10693
10972
  try:
10694
10973
  exchange = symbol_entry.get("exchange", "NASDAQ")
10695
10974
  symbol = symbol_entry.get("symbol", "")
@@ -10739,7 +11018,7 @@ class FundamentalNodeExecutor(NodeExecutorBase):
10739
11018
  context.log("warning", f"No data for {exchange}:{symbol}", node_id)
10740
11019
 
10741
11020
  except Exception as e:
10742
- context.log("warning", f"Failed to fetch {exchange}:{symbol}: {e}", node_id)
11021
+ context.log("warning", f"Failed to fetch {exchange}:{symbol or symbol_entry!r}: {e}", node_id)
10743
11022
  continue
10744
11023
 
10745
11024
  return {"values": values}
@@ -10780,6 +11059,7 @@ class FundamentalNodeExecutor(NodeExecutorBase):
10780
11059
  values = []
10781
11060
 
10782
11061
  for symbol_entry in symbols:
11062
+ symbol = "" # handler-safe default: the except below must never touch an unassigned name (UnboundLocalError masked the real cause)
10783
11063
  try:
10784
11064
  symbol = symbol_entry.get("symbol", "")
10785
11065
  if not symbol:
@@ -10812,7 +11092,7 @@ class FundamentalNodeExecutor(NodeExecutorBase):
10812
11092
  context.log("warning", f"No data for KRX:{symbol}", node_id)
10813
11093
 
10814
11094
  except Exception as e:
10815
- context.log("warning", f"Failed to fetch KRX:{symbol}: {e}", node_id)
11095
+ context.log("warning", f"Failed to fetch KRX:{symbol or symbol_entry!r}: {e}", node_id)
10816
11096
  continue
10817
11097
 
10818
11098
  return {"values": values}
@@ -10998,7 +11278,16 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10998
11278
  # / ConditionNode 의 `item.time_series`)이 조용히 None 이 되고 방어적
10999
11279
  # `data or []` 가 그걸 삼켜 에러 없이 빈 결과(count:0)를 낸다.
11000
11280
  # (deep_fixtures.historical_data_fixture 도 이 스키마를 못박는다.)
11001
- context.log("warning", "No symbols provided", node_id)
11281
+ # D2: 상류 배열이 비어 반복이 없었던 것(정상 no-signal)은 info, 소스 미연결/
11282
+ # 미해석 바인딩(설계 결함)만 warning("No symbols provided …") — 챗봇 저장
11283
+ # 게이트가 이 문구를 결함으로 읽으므로 정상 케이스에는 찍지 않는다.
11284
+ kind, detail = classify_empty_symbol_source(
11285
+ node_id, config, context, kwargs.get("workflow"), keys=("symbol", "symbols")
11286
+ )
11287
+ if kind == EMPTY_SYMBOLS_NO_SIGNAL:
11288
+ context.log("info", f"No symbols to fetch in this run — {detail}", node_id)
11289
+ else:
11290
+ context.log("warning", f"No symbols provided — {detail}", node_id)
11002
11291
  return {
11003
11292
  "value": None,
11004
11293
  "values": [],
@@ -12757,18 +13046,45 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
12757
13046
  kelly_fraction = float(evaluated.get("kelly_fraction", 0.25))
12758
13047
  atr_risk_percent = float(evaluated.get("atr_risk_percent", 1.0))
12759
13048
 
13049
+ # 상류가 error 신호를 동봉한 dict(예: `{"error": …, "values": []}`)를 symbols 로
13050
+ # 넘겼으면 fetch_failed. 정규화 **전에** 본다 — `_normalize_symbols` 의 dict 분기는
13051
+ # `{종목: {...}}` 맵 전용이라, error 페이로드를 넣으면 "error"/"values" 라는 가짜
13052
+ # 종목 2건이 만들어져 사이징이 그대로 진행됐다(빈-목록 분기에 영영 안 닿음).
13053
+ if isinstance(symbols_input, dict) and symbols_input.get("error"):
13054
+ err = str(symbols_input.get("error"))
13055
+ context.log(
13056
+ "warning",
13057
+ f"No symbols provided for position sizing — upstream fetch failed: {err}",
13058
+ node_id,
13059
+ )
13060
+ return self._empty_result(EmptyOrderReason.FETCH_FAILED, err)
13061
+
12760
13062
  # 종목 리스트 정규화
12761
13063
  symbols = self._normalize_symbols(symbols_input)
12762
13064
 
12763
13065
  if not symbols:
12764
- context.log("warning", "No symbols provided for position sizing", node_id)
12765
- # 상류가 빈 리스트에 error 신호를 동봉했으면 fetch_failed, 아니면 종목 미지정.
12766
- err = None
12767
- if isinstance(symbols_input, dict) and symbols_input.get("error"):
12768
- err = str(symbols_input.get("error"))
12769
- if err:
12770
- return self._empty_result(EmptyOrderReason.FETCH_FAILED, err)
12771
- return self._empty_result(EmptyOrderReason.NO_SYMBOL, "")
13066
+ # D2: "오늘 신호 없음(정상)" 과 "종목 소스 미연결/미해석(설계 결함)" 을 가른다.
13067
+ # 전자는 warning 을 찍지 않는다 — 챗봇 저장 게이트가 "No symbols provided" 를
13068
+ # 설계 결함으로 읽어, 올바로 배선된 워크플로우가 모의 실행 표본에서 조건 통과
13069
+ # 종목이 0 이라는 이유만으로 저장되지 못했다(벤치 본선 최대 실패 원인).
13070
+ kind, detail = classify_empty_symbol_source(
13071
+ node_id, evaluated, context, kwargs.get("workflow"), keys=("symbols", "symbol")
13072
+ )
13073
+ if kind == EMPTY_SYMBOLS_NO_SIGNAL:
13074
+ context.log(
13075
+ "info",
13076
+ f"No symbols to size in this run — {detail}; sizing skipped",
13077
+ node_id,
13078
+ )
13079
+ return self._empty_result(EmptyOrderReason.NO_SIGNAL, detail)
13080
+ context.log(
13081
+ "warning",
13082
+ f"No symbols provided for position sizing — {detail}",
13083
+ node_id,
13084
+ )
13085
+ if kind == EMPTY_SYMBOLS_UPSTREAM_FAILED:
13086
+ return self._empty_result(EmptyOrderReason.FETCH_FAILED, detail)
13087
+ return self._empty_result(EmptyOrderReason.NO_SYMBOL, detail)
12772
13088
 
12773
13089
  # Refuse to silently zero-out when the upstream AccountNode flagged
12774
13090
  # a partial fetch. fixed_quantity ignores balance entirely, so it
@@ -12812,6 +13128,18 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
12812
13128
  or "balance fetch partially failed"
12813
13129
  )
12814
13130
  return self._empty_result(EmptyOrderReason.FETCH_FAILED, detail)
13131
+ # 잔고 입력 자체가 없거나 미해석(`{{ … }}` 리터럴) = 읽지 못한 것이지 "오늘
13132
+ # 신호 없음" 이 아니다. 종목은 있는데 잔고가 안 묶인 채 no_signal 로 흘리면
13133
+ # 하류 주문 노드(D2 상속)까지 조용한 no-op 이 된다. 실제로 잔고 dict 가 있고
13134
+ # 매수가능금액이 0 인 경우만 정상 no_signal(현금 없음).
13135
+ if balance_data in (None, "", {}, [], ()) or (
13136
+ isinstance(balance_data, str) and "{{" in balance_data
13137
+ ):
13138
+ return self._empty_result(
13139
+ EmptyOrderReason.FETCH_FAILED,
13140
+ "balance input is missing or unresolved — bind `balance` to "
13141
+ "AccountNode.balance (every sizing method except fixed_quantity needs it)",
13142
+ )
12815
13143
  return self._empty_result(
12816
13144
  EmptyOrderReason.NO_SIGNAL,
12817
13145
  "No available balance for position sizing",
@@ -13914,7 +14242,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13914
14242
  if not normalized_order:
13915
14243
  context.log("warning", f"{node_type}: 주문할 종목이 없습니다", node_id)
13916
14244
  reason, detail = self._diagnose_empty_reason(
13917
- order, config, raw_order_expr, context
14245
+ order, config, raw_order_expr, context, node_id=node_id
13918
14246
  )
13919
14247
  return self._empty_result(reason, detail)
13920
14248
 
@@ -14144,6 +14472,7 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14144
14472
  config: Dict[str, Any],
14145
14473
  raw_order_expr: Any = None,
14146
14474
  context: Optional[ExecutionContext] = None,
14475
+ node_id: Optional[str] = None,
14147
14476
  ) -> tuple:
14148
14477
  """빈 주문 결과의 reason 을 상류 신호로 판정.
14149
14478
 
@@ -14194,6 +14523,23 @@ class NewOrderNodeExecutor(NodeExecutorBase):
14194
14523
  if err is not None:
14195
14524
  return EmptyOrderReason.FETCH_FAILED, err
14196
14525
 
14526
+ # 3.5) 상류가 명시적으로 "오늘 신호 없음"(reason=no_signal) 을 냈으면 정상 no-op.
14527
+ # D2(벤치 2026-09-06): 사이징이 빈 종목 목록(조건 미통과)을 받아 orders=[] 를
14528
+ # 내면 주문 노드는 order=None 을 받는데, 아래 5) 가 그걸 "종목 미지정(no_symbol)"
14529
+ # 으로 분류해 `_order_failure_from_outputs` 가 매 실행 "설정 누락" 으로 보고했다.
14530
+ # 상류의 no_signal 은 실패 신호(error/_partial_failure/fetch_failed)가 없을 때만
14531
+ # 그대로 물려받는다.
14532
+ for candidate in (upstream, _input_namespace(context, node_id) if (context is not None and node_id) else None):
14533
+ if (
14534
+ isinstance(candidate, dict)
14535
+ and candidate.get("reason") == EmptyOrderReason.NO_SIGNAL.value
14536
+ and self._extract_upstream_error(candidate) is None
14537
+ ):
14538
+ return (
14539
+ EmptyOrderReason.NO_SIGNAL,
14540
+ str(candidate.get("detail") or candidate.get("message") or ""),
14541
+ )
14542
+
14197
14543
  # 4) 상류가 낸 빈 리스트에 실패 신호 동봉 → 조회 실패(파이프라인 고장).
14198
14544
  # order/symbols 바인딩 결과가 dict 이면서 error/_partial_failure/
14199
14545
  # reason='fetch_failed' 를 들고 있는 경우를 본다.
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.33.2"
8
+ version = "1.33.4"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -30,7 +30,7 @@ pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
30
30
  aiosqlite = "^0.20.0"
31
31
  litellm = ">=1.40.0"
32
32
  programgarden-core = "^1.25.0"
33
- programgarden-finance = "^1.9.1"
33
+ programgarden-finance = "^1.9.3"
34
34
  programgarden-community = "^1.15.0"
35
35
 
36
36
  [tool.poetry.group.dev.dependencies]
File without changes