programgarden 1.37.1__tar.gz → 1.37.3__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 (39) hide show
  1. {programgarden-1.37.1 → programgarden-1.37.3}/PKG-INFO +1 -1
  2. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/executor.py +738 -113
  3. {programgarden-1.37.1 → programgarden-1.37.3}/pyproject.toml +1 -1
  4. {programgarden-1.37.1 → programgarden-1.37.3}/README.md +0 -0
  5. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/__init__.py +0 -0
  6. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/client.py +0 -0
  8. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/code_worker.py +0 -0
  9. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/context.py +0 -0
  10. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/futures_pnl.py +0 -0
  17. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/order_lifecycle.py +0 -0
  19. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/plugin/__init__.py +0 -0
  20. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/plugin/sandbox.py +0 -0
  21. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/providers/__init__.py +0 -0
  22. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/providers/llm_errors.py +0 -0
  23. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/providers/llm_provider.py +0 -0
  24. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/reconnect_handler.py +0 -0
  25. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resolver.py +0 -0
  26. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resource/__init__.py +0 -0
  27. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resource/context.py +0 -0
  28. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resource/limiter.py +0 -0
  29. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resource/monitor.py +0 -0
  30. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/resource/throttle.py +0 -0
  31. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/semantic_rules.py +0 -0
  32. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/__init__.py +0 -0
  33. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/credential_tools.py +0 -0
  34. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/definition_tools.py +0 -0
  35. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/event_tools.py +0 -0
  36. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/job_tools.py +0 -0
  37. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/registry_tools.py +0 -0
  38. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/tools/sqlite_tools.py +0 -0
  39. {programgarden-1.37.1 → programgarden-1.37.3}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.37.1
3
+ Version: 1.37.3
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -229,6 +229,65 @@ REALTIME_NODE_TYPES: frozenset = frozenset({
229
229
  })
230
230
 
231
231
 
232
+ # 노드 타입 → 선언된 출력 포트((이름, 타입) 튜플). 노드를 **실행하지 않고** 출력만
233
+ # 심어야 할 때(상류 조건 통과 0건 스킵), 그 노드가 평소 내보내던 포트 집합을 그대로
234
+ # 빈 값으로 채우기 위한 조회다.
235
+ #
236
+ # 🔴 왜 필요한가 — 스킵 출력을 `{result, reason, ...}` 한 봉투로 **교체**하면 하류가
237
+ # 조용히 오작동한다: ConditionNode 의 `result` 가 bool 이 아니라 [] 가 되어
238
+ # `{{ nodes.cond.result }} == true` 비교가 깨지고, `passed_symbols` 포트가 아예
239
+ # 사라져 LogicNode 가 그 조건을 'symbol-bearing' 이 아니라 'boolean-gate' 로
240
+ # 재분류한다(None 과 [] 를 구분하는 설계 — `LogicNodeExecutor` 참조).
241
+ # 그래서 스킵은 "출력 교체" 가 아니라 "선언된 포트를 빈 값으로" 여야 한다.
242
+ _DECLARED_OUTPUT_PORTS_CACHE: Dict[str, Tuple[Tuple[str, str], ...]] = {}
243
+
244
+
245
+ def _declared_output_ports(node_type: str) -> Tuple[Tuple[str, str], ...]:
246
+ """``node_type`` 이 선언한 출력 포트 ``((name, type), ...)``. 못 찾으면 빈 튜플."""
247
+ cached = _DECLARED_OUTPUT_PORTS_CACHE.get(node_type)
248
+ if cached is not None:
249
+ return cached
250
+ ports: Tuple[Tuple[str, str], ...] = ()
251
+ try:
252
+ from programgarden_core.registry.node_registry import NodeTypeRegistry
253
+
254
+ node_class = NodeTypeRegistry().get(node_type)
255
+ raw = getattr(node_class, "_outputs", None) if node_class is not None else None
256
+ # pydantic v2 는 `_outputs` 를 ModelPrivateAttr 로 감싼다 — .default 에 원본이 있다.
257
+ declared = getattr(raw, "default", raw)
258
+ if isinstance(declared, list):
259
+ ports = tuple(
260
+ (getattr(port, "name"), str(getattr(port, "type", "") or ""))
261
+ for port in declared
262
+ if getattr(port, "name", None)
263
+ )
264
+ except Exception as exc: # pragma: no cover - 레지스트리 없는 환경/커뮤니티 노드
265
+ logger.debug("_declared_output_ports(%s) failed: %s", node_type, exc)
266
+ ports = ()
267
+ _DECLARED_OUTPUT_PORTS_CACHE[node_type] = ports
268
+ return ports
269
+
270
+
271
+ # 포트 타입별 "값 없음" 표현. 배열 계열이 기본값이다 — 선언에 없는 타입이 와도
272
+ # 하류 `{{ ... }}` 가 None 이 아니라 빈 배열로 풀리는 쪽이 안전하다.
273
+ _EMPTY_PORT_SCALARS: Dict[str, Any] = {
274
+ "boolean": False,
275
+ "number": 0,
276
+ "integer": 0,
277
+ "float": 0,
278
+ "string": "",
279
+ }
280
+
281
+
282
+ def _empty_port_value(port_type: str) -> Any:
283
+ """선언된 포트 타입에 맞는 빈 값."""
284
+ if port_type in _EMPTY_PORT_SCALARS:
285
+ return _EMPTY_PORT_SCALARS[port_type]
286
+ if port_type in ("object", "order", "connection", "account"):
287
+ return {}
288
+ return []
289
+
290
+
232
291
  def _order_failure_from_outputs(outputs: Any) -> Optional[str]:
233
292
  """Return a human-readable reason if an order node *failed without raising*.
234
293
 
@@ -248,6 +307,19 @@ def _order_failure_from_outputs(outputs: Any) -> Optional[str]:
248
307
  """
249
308
  if not isinstance(outputs, dict):
250
309
  return None
310
+
311
+ # 🔴 auto-iterate 병합 생존선(2026-09-14 사고 후속). `order_result` 는 배열 병합
312
+ # 대상이 아니라 **마지막 항목의 값만** 남는다 — 3종목 중 2건이 입력 미해결로
313
+ # 차단되고 마지막 1건이 성공하면 병합 결과가 성공으로 보여 노드가 COMPLETED 가
314
+ # 되고 차단 사실이 노드 출력·통계·알림 어디에도 안 남았다. 그래서 차단 항목은
315
+ # 배열 포트(`blocked_orders`)에 모으고(=병합에서 살아남는다) 여기서 **먼저** 본다.
316
+ blocked = outputs.get("blocked_orders")
317
+ if isinstance(blocked, list) and blocked:
318
+ first = blocked[0] if isinstance(blocked[0], dict) else {}
319
+ ko = str(first.get("message_ko") or first.get("detail") or "")
320
+ head = f"unresolved_order_input: {len(blocked)}건의 주문이 입력 미해결로 차단되었습니다"
321
+ return f"{head} — {ko}" if ko else head
322
+
251
323
  result = outputs.get("order_result")
252
324
  if not isinstance(result, dict) or result.get("success") is not False:
253
325
  return None
@@ -256,8 +328,15 @@ def _order_failure_from_outputs(outputs: Any) -> Optional[str]:
256
328
  if reason == EmptyOrderReason.NO_SIGNAL.value:
257
329
  return None
258
330
 
331
+ # `message_ko` 를 앞에 둔다 — 이 문자열이 node_state 의 `error` 로 그대로 실려
332
+ # 대시보드·파드 SSE 에 나가는데, 종전에는 영어 detail 만 사용자에게 갔다.
259
333
  parts = [
260
- str(p) for p in (result.get("message"), result.get("detail"), result.get("error"))
334
+ str(p) for p in (
335
+ result.get("message_ko"),
336
+ result.get("message"),
337
+ result.get("detail"),
338
+ result.get("error"),
339
+ )
261
340
  if p
262
341
  ]
263
342
  label = reason or "order_failed"
@@ -950,7 +1029,8 @@ def _referenced_node_failure(expr: Optional[str], context: "ExecutionContext") -
950
1029
 
951
1030
  # 심볼을 나르는 포트 — `{{ item }}` 이 반복 밖에서 리터럴로 남았을 때 "상류 배열이 비어
952
1031
  # 반복이 안 일어났다" 를 판정할 축. 메인 루프의 auto-iterate 소스 우선순위(명시 from_port
953
- # > symbols > 첫 출력)와 맞춘다: ConditionNode 는 0건 통과일 때도 ``symbols``(평가 대상 전체)·
1032
+ # > symbols[+passed_symbols 승격] > 첫 출력 — `WorkflowJob._select_auto_iterate_source`)와
1033
+ # 맞춘다: ConditionNode 는 0건 통과일 때도 ``symbols``(평가 대상 전체)·
954
1034
  # ``failed_symbols``·``symbol_results`` 가 **비어 있지 않으므로**, 모든 리스트 포트가 비어야
955
1035
  # 한다는 규칙은 D2 가 없애려던 바로 그 케이스(조건 미통과 → `symbol: {{ item }}` 경고)를
956
1036
  # 남긴다. passed_symbols 가 있으면 그것만, 없으면 symbols, 둘 다 없으면 전체 리스트 포트.
@@ -10782,6 +10862,17 @@ class ConditionNodeExecutor(NodeExecutorBase):
10782
10862
  "positions (signal-independent flow exercise)",
10783
10863
  node_id,
10784
10864
  )
10865
+ # 🔴 `symbols` 는 **평가 대상 전체**의 종목코드 문자열 배열이다 —
10866
+ # 통과 목록이 아니다. 하류가 반복할 정본은 `passed_symbols`
10867
+ # (symbol/exchange/quantity/close_side dict) 하나뿐이다.
10868
+ #
10869
+ # 2026-09-14 prod 실관측: 손절(StopLoss) 하류 매도 노드가 이
10870
+ # `symbols` 문자열 3개를 순회해 `{{ item.symbol }}` 이 전부 None 으로
10871
+ # 풀렸고 **증권사 주문내역 0건** — NIO -10.5% 에서도 손절이 안 나갔다.
10872
+ # 항목이 dict 였다면 반대로 **미통과 MARA 까지 전량 매도**됐을 것이다.
10873
+ # 이 키를 지우지는 않는다(기존 워크플로우가 표시·집계에 쓴다).
10874
+ # 대신 auto-iterate 우선순위에서 passed_symbols 가 앞선다 —
10875
+ # `WorkflowJob._select_auto_iterate_source` 참조.
10785
10876
  return {
10786
10877
  "symbols": [p.get("symbol") for p in positions if isinstance(p, dict)],
10787
10878
  "result": True if (getattr(context, "is_deep_validate", False) and _passed) else result.get("result", False),
@@ -10803,6 +10894,8 @@ class ConditionNodeExecutor(NodeExecutorBase):
10803
10894
  else:
10804
10895
  # 플러그인 없으면 모두 통과
10805
10896
  passed_symbols = [{"symbol": s, "exchange": "UNKNOWN"} for s in positions.keys()]
10897
+ # `symbols` = 평가 대상 전체(문자열). 하류 반복의 정본은 passed_symbols.
10898
+ # (2026-09-14 사고 — 위 positions 분기 주석 참조.)
10806
10899
  return {
10807
10900
  "symbols": list(positions.keys()),
10808
10901
  "result": True,
@@ -11100,6 +11193,10 @@ class ConditionNodeExecutor(NodeExecutorBase):
11100
11193
  )
11101
11194
 
11102
11195
  return {
11196
+ # 🔴 `symbols` 는 **입력(평가 대상) 전체**다 — 통과 목록이 아니다. 0건 통과일
11197
+ # 때도 비어 있지 않으므로, 하류가 이걸 반복하면 조건을 통과하지 못한 종목까지
11198
+ # 주문 대상이 된다(2026-09-14 prod 손절 사고). **조건 노드 하류의 반복 정본은
11199
+ # `passed_symbols`** — `WorkflowJob._select_auto_iterate_source` 참조.
11103
11200
  "symbols": normalized_symbols, # 입력 symbols (거래소 포함)
11104
11201
  "result": len(passed_symbols) > 0,
11105
11202
  "is_condition_met": len(passed_symbols) > 0, # alias of result, documented in node examples
@@ -15413,11 +15510,72 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15413
15510
  normalized_order = self._normalize_order(order, config, context, node_id)
15414
15511
 
15415
15512
  if not normalized_order:
15416
- context.log("warning", f"{node_type}: 주문할 종목이 없습니다", node_id)
15513
+ # 🔴 조용히 끝내지 않는다(2026-09-14 prod 손절 사고). 종전에는 어느 필드가
15514
+ # 왜 비었는지 없이 "주문할 종목이 없습니다" 한 줄만 남겨, 상류가 문자열을
15515
+ # 반복시켜 symbol/exchange/quantity 가 전부 None 이 된 것과 "오늘 신호 없음"
15516
+ # 이 사람 눈에 똑같이 보였다. 어느 필드가 비었는지 + 반복 항목의 모양을
15517
+ # 구체적으로 적는다.
15518
+ #
15519
+ # ⚠️ **순서가 중요하다.** 진단(`_describe_unfilled_order_fields`)이
15520
+ # 분류(`_diagnose_empty_reason`)를 **선점하면 안 된다** — 선점하면
15521
+ # ① 계좌 조회 부분 실패(`balance._partial_failure`)한 날의 브로커 장애
15522
+ # 원문이 "설정 누락" 으로 덮이고, ② 상류가 정직하게 `reason=no_signal`
15523
+ # 을 낸 정상 무신호 날이 매 실행 노드 FAILED + 사용자 알림으로 승격된다
15524
+ # (D2, 벤치 2026-09-06 이 없애려던 바로 그 증상). 그래서 분류를 먼저
15525
+ # 돌리고, **확실한 배선 고장**(`hard`)일 때만 NO_SYMBOL 로 승격한다.
15526
+ unfilled = self._describe_unfilled_order_fields(order, context, node_type, node_id)
15417
15527
  reason, detail = self._diagnose_empty_reason(
15418
15528
  order, config, raw_order_expr, context, node_id=node_id
15419
15529
  )
15420
- return self._empty_result(reason, detail)
15530
+ hard = bool(unfilled and unfilled["hard"])
15531
+ if hard:
15532
+ reason, detail = EmptyOrderReason.NO_SYMBOL, unfilled["detail"]
15533
+ context.log("warning", f"{node_type}: {unfilled['message_ko']}", node_id)
15534
+ # 🔴 `context.log` 는 리스너(대시보드 SSE)로만 나가고 파이썬 logging 을
15535
+ # 거치지 않는다 — 즉 **`kubectl logs pg-worker` 에 안 보인다.** 이번
15536
+ # 사고를 진단한 표면이 바로 그 파드 stdout 이었으므로, 이 사건만은
15537
+ # stdout 에도 남긴다. `unresolved_order_input` 는 grep 용 고정 토큰이다.
15538
+ logger.warning(
15539
+ "unresolved_order_input | node=%s type=%s unfilled=%s item=%s | %s",
15540
+ node_id, node_type, unfilled["fields"], unfilled["item_shape"],
15541
+ unfilled["detail"],
15542
+ )
15543
+ _safe_print(
15544
+ f" ⛔ unresolved_order_input: {node_id} ({node_type}) — "
15545
+ f"{unfilled['detail']}"
15546
+ )
15547
+ await self._notify_unresolved_order_input(
15548
+ context, node_id, node_type, unfilled,
15549
+ )
15550
+ else:
15551
+ context.log("warning", f"{node_type}: 주문할 종목이 없습니다", node_id)
15552
+ result = self._empty_result(reason, detail)
15553
+ if unfilled is not None:
15554
+ # 진단은 hard/soft 무관하게 붙인다(어느 필드가 비었는지는 늘 유용하다).
15555
+ # 다만 **사유(reason)를 바꾸는 것은 hard 일 때뿐**이다.
15556
+ result["order_result"].update({
15557
+ "unfilled_fields": unfilled["fields"],
15558
+ "item_shape": unfilled["item_shape"],
15559
+ "message_ko": unfilled["message_ko"],
15560
+ })
15561
+ if hard:
15562
+ # ⚠️ resilience.fallback.mode=skip 의 `_skipped` 와 **다른 사건**이다 —
15563
+ # 저건 실행 중 예외를 재시도까지 소진하고 삼킨 것이고, 이건 애초에
15564
+ # 입력이 안 채워져 브로커에 아무것도 보내지 않은 것이다. 키를 나눈다.
15565
+ result["order_result"]["skipped_by"] = "unresolved_order_input"
15566
+ # auto-iterate 병합에서 살아남는 **배열** 포트. `order_result` 는 단일
15567
+ # 필드라 마지막 항목 값만 남아 "3건 중 2건 차단" 이 소실된다
15568
+ # (`_order_failure_from_outputs` / `_merge_iterate_results` 참조).
15569
+ result["blocked_orders"] = [{
15570
+ "node_id": node_id,
15571
+ "node_type": node_type,
15572
+ "reason": "unresolved_order_input",
15573
+ "unfilled_fields": unfilled["fields"],
15574
+ "item_shape": unfilled["item_shape"],
15575
+ "detail": unfilled["detail"],
15576
+ "message_ko": unfilled["message_ko"],
15577
+ }]
15578
+ return result
15421
15579
 
15422
15580
  context.log(
15423
15581
  "info",
@@ -15699,6 +15857,260 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15699
15857
  )
15700
15858
  return normalized
15701
15859
 
15860
+ # 주문을 만들려면 반드시 채워져야 하는 필드 — 사람이 읽는 이름과 함께.
15861
+ _ORDER_FIELD_LABELS = {
15862
+ "symbol": "종목코드",
15863
+ "exchange": "거래소",
15864
+ "quantity": "수량",
15865
+ }
15866
+
15867
+ @staticmethod
15868
+ def _required_order_fields(node_type: str) -> Tuple[str, ...]:
15869
+ """이 주문 노드가 채워져 있어야 하는 필드.
15870
+
15871
+ ⚠️ ``exchange`` 는 **국내주식에서 빼야 한다** — `_execute_korea_stock` 은
15872
+ exchange 를 한 번도 읽지 않고, `_normalize_order` 는 없으면 "NASDAQ" 으로
15873
+ 채운다. 그런데도 필수로 세면 거래소를 안 쓴 정상 국내주식 주문
15874
+ (자금 부족으로 수량 0 인 날)이 "거래소(exchange)가 비었습니다" 라는 **틀린
15875
+ 안내**와 함께 매 실행 보고된다.
15876
+ """
15877
+ if "KoreaStock" in node_type:
15878
+ return ("symbol", "quantity")
15879
+ return ("symbol", "exchange", "quantity")
15880
+
15881
+ @staticmethod
15882
+ def _unfilled_kind(key: str, value: Any) -> Optional[str]:
15883
+ """필드가 안 채워졌으면 그 종류를, 채워졌으면 None.
15884
+
15885
+ - ``missing`` : None / 빈 문자열
15886
+ - ``unresolved_template``: '{{ ... }}' 리터럴이 그대로 남음 (= **배선 고장**)
15887
+ - ``invalid`` : 숫자여야 하는데 숫자가 아님 (= **배선 고장**)
15888
+ - ``zero`` : 수량이 0 이하 (자금 부족·소수점 잔량 등 **정상일 수 있음**)
15889
+ """
15890
+ if value is None:
15891
+ return "missing"
15892
+ if isinstance(value, str):
15893
+ stripped = value.strip()
15894
+ if not stripped:
15895
+ return "missing"
15896
+ # 🔴 미해석 템플릿은 "채워진 값" 이 아니다. 종전 판정(None/공백만 빈 값)은
15897
+ # `{{ item.symbol }}` 리터럴을 **채워진 것**으로 봐서, `_normalize_order` 의
15898
+ # C23 가드가 주문을 거부한 뒤에도 사유가 `no_signal`("오늘 신호 없음")로
15899
+ # 라벨됐다 — 바인딩이 깨져 주문이 못 나간 것을 화면이 정상이라 설명했다.
15900
+ if "{{" in stripped and "}}" in stripped:
15901
+ return "unresolved_template"
15902
+ if key == "quantity":
15903
+ try:
15904
+ if float(value) <= 0:
15905
+ return "zero"
15906
+ except (TypeError, ValueError):
15907
+ return "invalid"
15908
+ return None
15909
+
15910
+ @staticmethod
15911
+ def _describe_unfilled_order_fields(
15912
+ order: Any,
15913
+ context: Optional["ExecutionContext"] = None,
15914
+ node_type: str = "",
15915
+ node_id: Optional[str] = None,
15916
+ ) -> Optional[Dict[str, Any]]:
15917
+ """주문 입력이 **채워지지 않아서** 주문을 못 만든 경우의 구체 사유.
15918
+
15919
+ 🔴 2026-09-14 prod 실관측:
15920
+ 손절 조건 하류의 매도 노드가 통과 종목 dict(`passed_symbols`) 대신 조건 노드의
15921
+ `symbols`(**문자열** 배열)를 순회했다. `{{ item.symbol }}` 은 문자열에 속성이
15922
+ 없어 None 으로 풀렸고, 세 종목 모두 symbol/exchange/quantity 가 비어 주문이
15923
+ 만들어지지 않았다 — 증권사 주문내역 0건. 그런데 로그에는 "주문할 종목이
15924
+ 없습니다" 한 줄뿐이라 사람이 원인을 짚을 수 없었다. 그래서 여기서 **반복 항목의
15925
+ 모양까지** 사유에 적는다.
15926
+
15927
+ ⚠️ 이 함수는 **사유를 기술할 뿐 분류를 대신하지 않는다.** 반환 dict 의 ``hard``
15928
+ 가 True 일 때만 호출부가 NO_SYMBOL 로 승격한다. "오늘 신호 없음"(상류가 정상
15929
+ 적으로 빈 결과) 과 "계좌 조회 실패" 판정은 `_diagnose_empty_reason` 의 몫이다 —
15930
+ 여기서 가로채면 정상 무신호 날이 매일 "주문 생성 실패" 로 울린다(D2 회귀).
15931
+
15932
+ Returns:
15933
+ {"fields", "kinds", "item_shape", "detail", "message_ko", "hard"} 또는 None.
15934
+ """
15935
+ # 반복 중인지 먼저 본다. `_iteration_item` 만 보면 ① "반복 안 함" 과 ② "반복 항목이
15936
+ # None" 이 구분되지 않고, ③ 앞 노드의 반복 항목이 정리되지 않고 남아 있으면 무관한
15937
+ # 노드가 남의 항목 모양을 자기 사유로 보고한다. `_iteration_total` 은 반복 중에만
15938
+ # 1 이상이다(`clear_iteration_context` 가 0 으로 되돌린다 — finally 보장).
15939
+ iterating = int(getattr(context, "_iteration_total", 0) or 0) > 0 if context is not None else False
15940
+ item = getattr(context, "_iteration_item", None) if context is not None else None
15941
+
15942
+ # C) 반복 항목 모양 방어 — 항목이 dict 가 아니면 수량·거래소를 담을 자리가 없다.
15943
+ # 문자열 항목을 "종목코드" 로 오해해 그 값으로 주문을 만들면 수량이 없는
15944
+ # 주문이 되거나(거부) 엉뚱한 수량이 실린다. 만들지 않고 사유를 남긴다.
15945
+ item_shape = None
15946
+ if iterating and not isinstance(item, dict):
15947
+ if item is None:
15948
+ item_shape = (
15949
+ "iteration item is None — the upstream array contained an empty entry"
15950
+ )
15951
+ else:
15952
+ item_shape = (
15953
+ f"iteration item is a {type(item).__name__} ({item!r}), not an object with "
15954
+ "symbol/exchange/quantity"
15955
+ )
15956
+
15957
+ if isinstance(order, dict):
15958
+ fields: List[str] = []
15959
+ kinds: Dict[str, str] = {}
15960
+ for key in NewOrderNodeExecutor._required_order_fields(node_type):
15961
+ kind = NewOrderNodeExecutor._unfilled_kind(key, order.get(key))
15962
+ if kind is not None:
15963
+ fields.append(key)
15964
+ kinds[key] = kind
15965
+ if not fields:
15966
+ return None
15967
+ detail = (
15968
+ "Order input was not filled in: "
15969
+ + ", ".join(f"{f}={order.get(f)!r} ({kinds[f]})" for f in fields)
15970
+ )
15971
+ if item_shape:
15972
+ detail += "; " + item_shape
15973
+ else:
15974
+ # dict 가 아닌 주문 입력. 상류가 **정상적으로** 빈 결과를 낸 경우(예: 사이징이
15975
+ # orders=[] + reason=no_signal)까지 여기서 "입력 미해결" 로 가로채면 "오늘 신호
15976
+ # 없음" 이 "설정 누락" 으로 뒤바뀐다(D2 회귀) — 그래서 **반복 항목의 모양이
15977
+ # 실제로 틀렸을 때만** 진단한다. 그 외는 None 을 돌려 기존 분류로 넘긴다.
15978
+ if item_shape is None:
15979
+ return None
15980
+ fields = list(NewOrderNodeExecutor._required_order_fields(node_type))
15981
+ kinds = {f: "missing" for f in fields}
15982
+ detail = (
15983
+ f"Order input is a {type(order).__name__} ({order!r}), not an order object "
15984
+ "with symbol/exchange/quantity; " + item_shape
15985
+ )
15986
+
15987
+ # 🔴 "확실한 배선 고장" 인가. 이것만 NO_SYMBOL 승격 + 사용자 알림 대상이다.
15988
+ # - 반복 항목 모양이 틀림(이번 사고)
15989
+ # - 미해석 템플릿 리터럴이 남음(C23 가 거부한 그 값)
15990
+ # - 수량이 숫자가 아님
15991
+ # 수량이 0/음수인 것만으로는 승격하지 않는다 — 자금 부족·소수점 잔량 등
15992
+ # **정상적으로 오늘 못 사는 날**이 있고, 그건 `_diagnose_empty_reason` 이
15993
+ # no_signal / fractional_only 로 더 정확히 분류한다.
15994
+ # 🔴 2026-09-14 prod 실관측(엔진 1.37.2 첫 판단) — 거짓 경보 교정.
15995
+ # 매수 노드가 `{{ item.symbol }}` 을 쓰는데 상류(SymbolFilter)가 **정상적으로**
15996
+ # 빈 목록을 냈다(관심종목을 이미 보유 → 오늘 살 것 없음). 반복이 아예 일어나지
15997
+ # 않아 템플릿이 리터럴로 남았는데, 그걸 `unresolved_template` = 배선 고장으로
15998
+ # 승격해 **매 실행 노드 FAILED + 사용자 알림**이 됐다. "오늘 살 게 없다" 는
15999
+ # 정상이다.
16000
+ # → 반복 중이 아닐 때 남은 **아이템 바인딩**(`{{ item… }}` / `{{ index }}`)은
16001
+ # 승격하지 않고 `_diagnose_empty_reason` 에 넘긴다. 그쪽은 상류 리스트 포트로
16002
+ # "상류가 비어 반복이 안 됨(no_signal)" 과 "배열 소스 없이 item 을 씀(unbound)"
16003
+ # 을 이미 가른다 — 이 함수의 계약대로 분류는 그쪽 몫이다.
16004
+ # 반복 **중인데도** 안 풀린 템플릿은 종전대로 고장이다(항목이 있는데 못 읽었다).
16005
+ def _is_item_binding(value: Any) -> bool:
16006
+ if not isinstance(value, str):
16007
+ return False
16008
+ return bool(re.search(r"\{\{\s*(item|index|total)\b", value))
16009
+
16010
+ # 판정 축은 "반복 중인가" 가 아니라 **상류 배열이 있는데 비었는가** 다.
16011
+ # · 상류 리스트 포트가 있고 비었다 → 오늘 줄 게 없어 반복이 안 된 것(정상)
16012
+ # · 리스트 포트가 아예 없다 → 배열 소스 없이 item 을 쓴 배선 고장
16013
+ # `_upstream_array_is_empty` 가 이미 그 셋(True/False/None)을 가른다.
16014
+ upstream_empty = False
16015
+ if not iterating and context is not None and node_id:
16016
+ try:
16017
+ upstream_empty = _upstream_array_is_empty(_input_namespace(context, node_id)) is True
16018
+ except Exception: # noqa: BLE001 — 진단이 실행을 막지 않는다
16019
+ upstream_empty = False
16020
+ hard_kinds = {
16021
+ key
16022
+ for key, kind in kinds.items()
16023
+ if kind == "invalid"
16024
+ or (
16025
+ kind == "unresolved_template"
16026
+ and not (upstream_empty and _is_item_binding(order.get(key)))
16027
+ )
16028
+ }
16029
+ hard = item_shape is not None or bool(hard_kinds)
16030
+
16031
+ labels = NewOrderNodeExecutor._ORDER_FIELD_LABELS
16032
+ kind_ko = {
16033
+ "missing": "비어 있음",
16034
+ "unresolved_template": "바인딩이 풀리지 않은 템플릿 그대로",
16035
+ "invalid": "숫자가 아님",
16036
+ "zero": "0 이하",
16037
+ }
16038
+ ko_fields = ", ".join(
16039
+ f"{labels.get(f, f)}({f})={kind_ko.get(kinds[f], kinds[f])}" for f in fields
16040
+ )
16041
+ message_ko = (
16042
+ f"주문 입력이 채워지지 않아 주문을 만들지 않았습니다 — 문제 항목: {ko_fields}."
16043
+ )
16044
+ if item_shape is not None:
16045
+ shape_ko = (
16046
+ "반복 항목이 비어 있습니다(None)."
16047
+ if item is None
16048
+ else f"반복 항목이 {type(item).__name__} 값({item!r})이라 수량·거래소를 담고 있지 않습니다."
16049
+ )
16050
+ message_ko += (
16051
+ f" {shape_ko} 상류 조건 노드의 `symbols`(평가 대상 전체 문자열)가 아니라 "
16052
+ "`passed_symbols`(통과 종목 객체)를 반복하도록 연결하세요."
16053
+ )
16054
+ else:
16055
+ message_ko += (
16056
+ " 상류 바인딩(`{{ item.* }}` / `{{ nodes.<id>.<port> }}`)이 값을 내지 못했습니다."
16057
+ )
16058
+
16059
+ return {
16060
+ "fields": fields,
16061
+ "kinds": kinds,
16062
+ "item_shape": item_shape,
16063
+ "detail": detail,
16064
+ "message_ko": message_ko,
16065
+ "hard": hard,
16066
+ }
16067
+
16068
+ @staticmethod
16069
+ async def _notify_unresolved_order_input(
16070
+ context: "ExecutionContext",
16071
+ node_id: str,
16072
+ node_type: str,
16073
+ unfilled: Dict[str, Any],
16074
+ ) -> None:
16075
+ """입력 미해결로 주문을 못 만든 사실을 사용자 알림 경로에도 올린다.
16076
+
16077
+ ORDER_REJECTED 를 쓰되 `blocked_before_broker=True` / `reason` 으로 브로커 거부와
16078
+ 구분한다(브로커 거부는 `rsp_cd` 를 들고 온다). 알림 실패가 주문 경로를 죽이면
16079
+ 안 되므로 예외는 삼킨다.
16080
+
16081
+ 🔴 **이 알림은 2026-09-14 현재 실배포에서 소비자가 없다 — 이것만 믿지 말 것.**
16082
+ `ExecutionContext.notify_notification` 은 `on_notification` 을 구현한 리스너
16083
+ 에게만 전달하는데, pg-worker `SSEListener` 도 트레이앱 `EventForwarder` 도
16084
+ 구현하지 않아 `BaseExecutionListener.on_notification`(pass)에서 끝난다.
16085
+ 그래서 이 사건이 **실제로 사람에게 도달하는 경로는 아래 셋**이다:
16086
+ 1. `logger.warning` + `_safe_print` → 파드 stdout(`kubectl logs | grep
16087
+ unresolved_order_input`)
16088
+ 2. 노드 출력 `order_result.skipped_by/unfilled_fields/message_ko`
16089
+ + 병합에서 살아남는 `blocked_orders` 배열
16090
+ 3. `_order_failure_from_outputs` → node_state FAILED 의 `error` 문자열
16091
+ (message_ko 를 앞에 싣는다) → 대시보드
16092
+ pg-worker/트레이앱에 `on_notification` 이 배선되면 이 주석을 지운다.
16093
+ """
16094
+ try:
16095
+ await context.send_notification(
16096
+ category=NotificationCategory.ORDER_REJECTED,
16097
+ severity=NotificationSeverity.WARNING,
16098
+ title="주문 생성 실패 — 입력이 비어 있습니다",
16099
+ message=unfilled["message_ko"],
16100
+ node_id=node_id,
16101
+ node_type=node_type,
16102
+ data={
16103
+ "blocked_before_broker": True,
16104
+ "reason": "unresolved_order_input",
16105
+ "unfilled_fields": unfilled["fields"],
16106
+ "unfilled_kinds": unfilled.get("kinds"),
16107
+ "item_shape": unfilled["item_shape"],
16108
+ "detail": unfilled["detail"],
16109
+ },
16110
+ )
16111
+ except Exception as exc: # pragma: no cover - 알림 실패는 주문 경로를 죽이지 않는다
16112
+ logger.warning("unresolved order input notification failed: %s", exc)
16113
+
15702
16114
  def _diagnose_empty_reason(
15703
16115
  self,
15704
16116
  order: Any,
@@ -15921,8 +16333,16 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15921
16333
 
15922
16334
  ord_mkt_code = self.STOCK_MARKET_CODES.get(exchange, "82")
15923
16335
 
15924
- # 매수 지정가 주문인데 가격이 없으면 현재가 조회
15925
- if is_buy and ordprc_ptn_code == "00" and price <= 0:
16336
+ # 지정가 주문인데 가격이 없으면 현재가 조회.
16337
+ # 🔴 2026-09-14 — 종전엔 **매수만** 이 경로를 탔다. 그래서 매도를 지정가로 두고 가격을
16338
+ # 비우면 price=0 이 그대로 나가 거부됐다. 미국주식 **주간거래(Blue Ocean) 세션은
16339
+ # 지정가만 받는다**(rsp_cd 00891 "주간거래는 지정가로만 주문이 가능합니다", 실계좌
16340
+ # 관측) — 그 시간대에 손절·익절을 쓰려면 매도도 지정가여야 하는데, 사람이 종목마다
16341
+ # 가격을 적을 수는 없다(손절 대상은 조건이 고른다). 매수와 같은 규칙으로 맞춘다.
16342
+ # ⚠️ 지정가는 **체결을 보장하지 않는다** — 손절인데 안 팔릴 수 있다. 그건 워크플로우가
16343
+ # 지정가를 선택한 결과이고, 노드 설정에 그대로 드러난다(엔진이 몰래 유형을 바꾸지
16344
+ # 않는다 — 오너 지시 2026-09-14: "다시 보낼 때는 노드로 워크플로우에 있어야 사용자가 안다").
16345
+ if ordprc_ptn_code == "00" and price <= 0:
15926
16346
  try:
15927
16347
  current_price = await self._get_current_price(ls, symbol, ord_mkt_code, context, node_id)
15928
16348
  if current_price and current_price > 0:
@@ -15956,6 +16376,14 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15956
16376
 
15957
16377
  # 디버그: 응답 전체 출력
15958
16378
  context.log("debug", f"COSAT00301 response: rsp_cd={response.rsp_cd}, rsp_msg={response.rsp_msg}", node_id)
16379
+ # 🔴 2026-09-14 — `context.log` 는 리스너 통지 전용이라 **stdout 에 안 나온다**.
16380
+ # 그래서 파드 로그만 보면 접수됐는지 거부됐는지 구분이 안 됐다(주문 결과 0줄).
16381
+ # 증권사 응답 코드는 사고 조사의 1차 근거라 모듈 로거로도 남긴다
16382
+ # (`kubectl logs | grep broker_order_response`).
16383
+ logger.info(
16384
+ "broker_order_response | node=%s tr=COSAT00301 symbol=%s rsp_cd=%s rsp_msg=%s",
16385
+ node_id, symbol, response.rsp_cd, response.rsp_msg,
16386
+ )
15959
16387
 
15960
16388
  if response.error_msg:
15961
16389
  reject = map_reject_code(
@@ -15966,6 +16394,10 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15966
16394
  f"Order failed: {symbol} - {response.error_msg} ({reject.cause})",
15967
16395
  node_id,
15968
16396
  )
16397
+ logger.warning(
16398
+ "broker_order_rejected | node=%s symbol=%s rsp_cd=%s cause=%s msg=%s",
16399
+ node_id, symbol, getattr(response, "rsp_cd", None), reject.cause, response.error_msg,
16400
+ )
15969
16401
  await self._notify_order_reject(context, node_id, symbol, reject)
15970
16402
  return self._order_result(
15971
16403
  False, symbol, exchange, side, qty, price,
@@ -15995,6 +16427,10 @@ class NewOrderNodeExecutor(NodeExecutorBase):
15995
16427
  )
15996
16428
 
15997
16429
  context.log("info", f"Order submitted: {symbol} {side} {qty}@{price} → order_id={order_no}", node_id)
16430
+ logger.info(
16431
+ "broker_order_submitted | node=%s symbol=%s side=%s qty=%s price=%s order_id=%s",
16432
+ node_id, symbol, side, qty, price, order_no,
16433
+ )
15998
16434
 
15999
16435
  # Record workflow order for FIFO tracking (OrderNo가 있는 경우만)
16000
16436
  context.record_workflow_order(
@@ -21256,74 +21692,66 @@ class WorkflowJob:
21256
21692
  try:
21257
21693
  # === 자동 iterate 체크 ===
21258
21694
  # 입력 데이터가 배열이고, 노드가 단일 아이템을 기대하면 자동으로 각 아이템마다 실행
21259
- input_data = None
21260
- # auto-iterate 소스 선택 — incoming 엣지를 **전부** 훑어 우선순위로 고른다.
21261
- # 예전엔 첫 매칭 엣지에서 무조건 break 해서 **엣지 선언 순서가 소스를 결정**했다:
21262
- # - 예제 16/28 은 account 엣지가 먼저라 sizing 이 **계좌 보유종목**을 순회했다
21263
- # (실측: 워크플로우 어디에도 없는 AUID 를 매수 후보로 처리 — 잔고가 충분했다면
21264
- # 보유종목에 실제 주문이 나갔다).
21265
- # - 예제 28 은 `logic.passed_symbols` 를 올바로 명시했는데도 앞선 account 엣지의
21266
- # break 에 가려 아래 explicit 분기까지 도달조차 못 했다.
21267
- # 우선순위: 명시 from_port > symbols 포트 > 소스 노드 첫 출력.
21268
- explicit_data = None
21269
- symbols_data = None
21270
- fallback_data = None
21271
-
21272
- for edge in self.workflow.edges:
21273
- if edge.to_node_id != node_id:
21274
- continue
21275
-
21276
- # 1순위: 명시적 from_port (예: ExclusionListNode.filtered,
21277
- # LogicNode.passed_symbols). IfNode 분기 포트(true/false/result)는
21278
- # 라우팅 의미라 소스에서 제외.
21279
- explicit_port = getattr(edge, "from_port", None)
21280
- if explicit_port and explicit_port not in (
21281
- "output", "true", "false", "result",
21282
- ):
21283
- port_data = self.context.get_output(
21284
- edge.from_node_id, explicit_port
21285
- )
21286
- if port_data is not None and explicit_data is None:
21287
- explicit_data = port_data
21288
-
21289
- # 2순위: symbols 포트 (Watchlist/MarketUniverse/SymbolFilter 등 배열 생성 노드)
21290
- if symbols_data is None:
21291
- port_symbols = self.context.get_output(edge.from_node_id, "symbols")
21292
- # symbols가 문자열 배열이면 (merge 후) value 포트로 폴백
21293
- # - WatchlistNode symbols: [{exchange, symbol}, ...] → dict 배열 → 그대로 사용
21294
- # - HistoricalDataNode merged symbols: ["TSLA"] → string 배열 → value 포트로 전환
21295
- if (isinstance(port_symbols, list) and port_symbols
21296
- and not isinstance(port_symbols[0], dict)):
21297
- value_data = self.context.get_output(edge.from_node_id, "value")
21298
- if value_data is not None:
21299
- if isinstance(value_data, list):
21300
- port_symbols = value_data
21301
- elif isinstance(value_data, dict):
21302
- port_symbols = [value_data]
21303
- if port_symbols is not None:
21304
- symbols_data = port_symbols
21305
-
21306
- # 3순위: 소스 노드의 첫 출력. 단 계좌 노드는 제외한다 —
21307
- # 첫 포트가 `positions`(보유잔고)라 매수 후보로 오인된다.
21308
- if fallback_data is None:
21309
- from_node = self.workflow.nodes.get(edge.from_node_id)
21310
- from_type = getattr(from_node, "node_type", None) if from_node else None
21311
- if from_type not in self.NO_ITERATE_SOURCE_NODE_TYPES:
21312
- fallback_data = self.context.get_output(edge.from_node_id, None)
21313
-
21314
- if explicit_data is not None:
21315
- input_data = explicit_data
21316
- elif symbols_data is not None:
21317
- input_data = symbols_data
21318
- else:
21319
- input_data = fallback_data
21695
+ input_data, iterate_source, iterate_source_node_id = (
21696
+ self._select_auto_iterate_source(node_id)
21697
+ )
21320
21698
 
21321
21699
  should_iterate, port_name, items = self._should_auto_iterate(
21322
21700
  node.node_type, input_data, config,
21323
21701
  )
21324
21702
 
21325
- auto_iterated = should_iterate and node_id not in branch_nodes
21326
- if auto_iterated:
21703
+ # 🔴 조건 게이트가 0건 통과시켰는데(= passed_symbols 가 **정본이고
21704
+ # 비었다**) 이 노드가 아이템 바인딩(`{{ item.* }}`)을 쓰거나 주문 노드면,
21705
+ # 한 번 돌려 봐야 아이템 바인딩이 전부 None 으로 풀려 "종목 없는 주문"
21706
+ # 한 건을 만들 뿐이고, 리터럴 주문이면 **조건을 통과하지 못한 종목에
21707
+ # 실주문**이 나간다. 실행 자체를 건너뛰고 사유를 남긴다 — 브로커 TR 0.
21708
+ # (2026-09-14 prod 사고의 안전한 쌍둥이: 여기서 `symbols`(평가 대상 전체)
21709
+ # 로 흘러내리면 **손절에 걸리지도 않은 종목까지 전량 매도**된다.)
21710
+ #
21711
+ # ⚠️ 아이템 바인딩 조건을 **떼면 안 된다** — 조건 노드를 게이트로만 물고
21712
+ # `{{ nodes.cond.symbols }}` 로 전체를 소비하는 알림/집계/표시 노드까지
21713
+ # 침묵한다. "통과 0건이면 아무것도 안 하는 게 정답" 은 **주문 노드**
21714
+ # 이야기이고, 상태 보고 노드는 그날도 말을 해야 한다(요구사항 4).
21715
+ skip_reason = None
21716
+ if (
21717
+ not should_iterate
21718
+ and iterate_source == self.ITERATE_SOURCE_PASSED_SYMBOLS
21719
+ and isinstance(input_data, list)
21720
+ and not input_data
21721
+ and (
21722
+ self._references_iteration_item(config)
21723
+ or node.node_type.endswith("OrderNode")
21724
+ )
21725
+ ):
21726
+ skip_reason = (
21727
+ f"upstream condition gate '{iterate_source_node_id}' passed 0 symbols "
21728
+ "(passed_symbols=[]) — skipped without executing, so no broker "
21729
+ "request was made"
21730
+ )
21731
+
21732
+ auto_iterated = (
21733
+ should_iterate and node_id not in branch_nodes and skip_reason is None
21734
+ )
21735
+ if skip_reason is not None:
21736
+ # 상류 노드 id 를 반드시 적는다 — 이게 없으면 "왜 몇 주째 주문이
21737
+ # 없는가" 를 로그만 보고 되짚을 수 없다. `no_upstream_signal` 은
21738
+ # grep 용 고정 토큰(`kubectl logs | grep no_upstream_signal`);
21739
+ # `context.log` 는 파드 stdout 에 안 찍히므로 _safe_print 를 병기한다.
21740
+ self.context.log(
21741
+ "info",
21742
+ f"{node.node_type}: 상류 조건 노드 '{iterate_source_node_id}' 의 통과 "
21743
+ f"종목이 0건이라 실행을 건너뜁니다 (no_upstream_signal — 주문/조회 "
21744
+ f"요청 없음).",
21745
+ node_id,
21746
+ )
21747
+ _safe_print(
21748
+ f" ⏭️ no_upstream_signal: {node_id} ({node.node_type}) "
21749
+ f"← {iterate_source_node_id}.passed_symbols=0 — not executed"
21750
+ )
21751
+ outputs = self._no_signal_skip_outputs(
21752
+ node.node_type, skip_reason, iterate_source_node_id,
21753
+ )
21754
+ elif auto_iterated:
21327
21755
  # 자동 iterate 실행 (SplitNode 브랜치가 아닌 경우에만)
21328
21756
  outputs = await self._execute_with_auto_iterate(
21329
21757
  node_id=node_id,
@@ -21691,6 +22119,193 @@ class WorkflowJob:
21691
22119
  # 리터럴 목록이거나(이미 평가된 배열 포함) 전체 바인딩 문자열
21692
22120
  return isinstance(value, (list, str, dict))
21693
22121
 
22122
+ # auto-iterate 소스 라벨 — 어느 우선순위 단계가 반복 대상을 골랐는지.
22123
+ # `_select_auto_iterate_source` 의 두 번째 반환값이며, 호출부가 "조건 게이트의 통과
22124
+ # 목록인가" 를 되물을 수 있게 한다(0건 통과 시 실행 자체를 건너뛰는 판정에 쓰인다).
22125
+ ITERATE_SOURCE_NONE = ""
22126
+ ITERATE_SOURCE_EXPLICIT = "explicit"
22127
+ ITERATE_SOURCE_PASSED_SYMBOLS = "passed_symbols"
22128
+ ITERATE_SOURCE_SYMBOLS = "symbols"
22129
+ ITERATE_SOURCE_FALLBACK = "fallback"
22130
+
22131
+ # IfNode 분기 포트 — 데이터가 아니라 라우팅 의미라 명시 from_port 로 쳐 주지 않는다.
22132
+ _NON_DATA_FROM_PORTS = ("output", "true", "false", "result")
22133
+
22134
+ def _select_auto_iterate_source(self, node_id: str) -> Tuple[Any, str, str]:
22135
+ """이 노드의 auto-iterate 반복 대상 / 출처 라벨 / 출처 노드 id 를 고른다.
22136
+
22137
+ incoming 엣지를 **전부** 훑어 우선순위로 고른다. 예전엔 첫 매칭 엣지에서 무조건
22138
+ break 해서 **엣지 선언 순서가 소스를 결정**했다:
22139
+ - 예제 16/28 은 account 엣지가 먼저라 sizing 이 **계좌 보유종목**을 순회했다
22140
+ (실측: 워크플로우 어디에도 없는 AUID 를 매수 후보로 처리 — 잔고가 충분했다면
22141
+ 보유종목에 실제 주문이 나갔다).
22142
+ - 예제 28 은 `logic.passed_symbols` 를 올바로 명시했는데도 앞선 account 엣지의
22143
+ break 에 가려 explicit 분기까지 도달조차 못 했다.
22144
+
22145
+ 🔴 우선순위: **명시 from_port > symbols(+passed_symbols 승격) > 소스 노드 첫 출력.**
22146
+
22147
+ 2026-09-14 prod 실관측 사고로 가운데 단계에 "승격" 이 붙었다. ConditionNode 는
22148
+ 통과 여부와 무관하게 ``symbols``(**평가 대상 전체**의 종목코드 **문자열** 배열)를
22149
+ 함께 내보낸다. 그래서 손절 조건(StopLoss) 하류의 매도 주문 노드가
22150
+
22151
+ - 통과 종목 dict(`passed_symbols`: symbol/exchange/quantity/close_side) 가 아니라
22152
+ - 문자열 3개(`symbols`: ["NIO", "MARA", "ZOMDF"]) 를 순회했고,
22153
+
22154
+ `{{ item.symbol }}` / `{{ item.exchange }}` / `{{ item.quantity }}` 가 전부 None 으로
22155
+ 풀려 **증권사 주문내역 0건** — 손절 -8% 를 10.5% 하락에서도 못 냈다(9/11~9/14).
22156
+ 그리고 이 항목이 dict 였다면 반대로 **손절에 걸리지 않은 MARA 까지 전량 매도**된다.
22157
+
22158
+ ⚠️ **승격은 "선택된 소스 노드 안에서" 만 한다 — 엣지를 가로지르지 않는다.**
22159
+ passed_symbols 를 독립 단계로 올려 symbols 단계보다 **먼저** 반환했더니 두 가지가
22160
+ 깨졌다:
22161
+ 1) 출하 예제 47/48 처럼 `hist -> touch_check` 와 `sr_detect(ConditionNode) ->
22162
+ touch_check` 가 함께 물린 배선에서, 진짜 반복 소스인 hist 의 시계열 대신
22163
+ 조건 노드의 통과 목록을 순회해 `items.from` 이 0행이 되고 전략이 통째로
22164
+ 신호 0 이 됐다(조용한 거래 중단).
22165
+ 2) LogicNode 는 `symbols` 포트가 없어 종전엔 첫 출력 `result`(bool)라 **반복이
22166
+ 아예 안 걸렸다**. passed_symbols 를 무조건 먼저 집으면 `logic -> order`
22167
+ (from_port 없음, 리터럴 주문) 배선이 1회 → N회가 되어 **같은 주문이 N번**
22168
+ 브로커로 나간다.
22169
+ 그래서 승격 조건은 "이 노드가 반복하기로 고른 바로 그 소스 노드가
22170
+ passed_symbols 도 함께 낸다" 이다 — 그게 사고를 낸 ConditionNode 의 모양이고,
22171
+ 예제 47/48(hist 가 소스) 도 LogicNode(symbols 없음) 도 여기에 걸리지 않는다.
22172
+
22173
+ 승격되면 **빈 배열이라도 정본**이다 — 절대 `symbols`(평가 대상 전체)로 흘러내리지
22174
+ 않는다. 그게 "통과 0건이면 아무것도 하지 않는다" 를 보장하는 지점이다.
22175
+
22176
+ Returns:
22177
+ (input_data, source_label, source_node_id)
22178
+ """
22179
+ explicit_data = None
22180
+ explicit_port_name = ""
22181
+ explicit_node_id = ""
22182
+ symbols_data = None
22183
+ symbols_node_id = ""
22184
+ fallback_data = None
22185
+ fallback_node_id = ""
22186
+
22187
+ for edge in self.workflow.edges:
22188
+ if edge.to_node_id != node_id:
22189
+ continue
22190
+
22191
+ # 1순위: 명시적 from_port (예: ExclusionListNode.filtered,
22192
+ # LogicNode.passed_symbols). IfNode 분기 포트(true/false/result)는
22193
+ # 라우팅 의미라 소스에서 제외.
22194
+ explicit_port = getattr(edge, "from_port", None)
22195
+ if explicit_port and explicit_port not in self._NON_DATA_FROM_PORTS:
22196
+ port_data = self.context.get_output(edge.from_node_id, explicit_port)
22197
+ if port_data is not None and explicit_data is None:
22198
+ explicit_data = port_data
22199
+ explicit_port_name = explicit_port
22200
+ explicit_node_id = edge.from_node_id
22201
+
22202
+ # 2순위: symbols 포트 (Watchlist/MarketUniverse/SymbolFilter/Condition 등)
22203
+ if symbols_data is None:
22204
+ port_symbols = self.context.get_output(edge.from_node_id, "symbols")
22205
+ # symbols가 문자열 배열이면 (merge 후) value 포트로 폴백
22206
+ # - WatchlistNode symbols: [{exchange, symbol}, ...] → dict 배열 → 그대로 사용
22207
+ # - HistoricalDataNode merged symbols: ["TSLA"] → string 배열 → value 포트로 전환
22208
+ if (isinstance(port_symbols, list) and port_symbols
22209
+ and not isinstance(port_symbols[0], dict)):
22210
+ value_data = self.context.get_output(edge.from_node_id, "value")
22211
+ if value_data is not None:
22212
+ if isinstance(value_data, list):
22213
+ port_symbols = value_data
22214
+ elif isinstance(value_data, dict):
22215
+ port_symbols = [value_data]
22216
+ if port_symbols is not None:
22217
+ symbols_data = port_symbols
22218
+ symbols_node_id = edge.from_node_id
22219
+
22220
+ # 3순위: 소스 노드의 첫 출력. 단 계좌 노드는 제외한다 —
22221
+ # 첫 포트가 `positions`(보유잔고)라 매수 후보로 오인된다.
22222
+ if fallback_data is None:
22223
+ from_node = self.workflow.nodes.get(edge.from_node_id)
22224
+ from_type = getattr(from_node, "node_type", None) if from_node else None
22225
+ if from_type not in self.NO_ITERATE_SOURCE_NODE_TYPES:
22226
+ candidate = self.context.get_output(edge.from_node_id, None)
22227
+ if candidate is not None:
22228
+ fallback_data = candidate
22229
+ fallback_node_id = edge.from_node_id
22230
+
22231
+ if explicit_data is not None:
22232
+ # 명시적으로 `passed_symbols` 를 가리킨 배선도 조건 게이트다 — 0건일 때
22233
+ # 하류를 건너뛰는 판정이 똑같이 걸려야 한다(라벨이 그 스위치다).
22234
+ label = (
22235
+ self.ITERATE_SOURCE_PASSED_SYMBOLS
22236
+ if explicit_port_name == "passed_symbols"
22237
+ else self.ITERATE_SOURCE_EXPLICIT
22238
+ )
22239
+ return explicit_data, label, explicit_node_id
22240
+ if symbols_data is not None:
22241
+ # 🔴 승격: 고른 소스 노드가 `passed_symbols` 도 낸다면(= ConditionNode 모양)
22242
+ # 그게 정본이다. 키가 있으면 **빈 배열이라도** 여기서 끝난다.
22243
+ port_passed = self.context.get_output(symbols_node_id, "passed_symbols")
22244
+ if isinstance(port_passed, list):
22245
+ return port_passed, self.ITERATE_SOURCE_PASSED_SYMBOLS, symbols_node_id
22246
+ return symbols_data, self.ITERATE_SOURCE_SYMBOLS, symbols_node_id
22247
+ if fallback_data is not None:
22248
+ return fallback_data, self.ITERATE_SOURCE_FALLBACK, fallback_node_id
22249
+ return None, self.ITERATE_SOURCE_NONE, ""
22250
+
22251
+ def _no_signal_skip_outputs(
22252
+ self,
22253
+ node_type: str,
22254
+ detail: str,
22255
+ source_node_id: str = "",
22256
+ ) -> Dict[str, Any]:
22257
+ """상류 조건 통과 0건으로 **실행하지 않은** 노드의 출력.
22258
+
22259
+ 🔴 **출력을 교체하지 않고, 선언된 포트를 빈 값으로 채운다.** 종전 구현은
22260
+ `{result: [], reason, message, detail, skipped_by}` 한 봉투로 갈아치웠는데,
22261
+ 그러면 하류가 조용히 오작동한다:
22262
+ - ConditionNode 의 `result` 가 bool 이 아니라 `[]` 가 되어
22263
+ `{{ nodes.cond.result }} == true` 비교가 깨진다.
22264
+ - `passed_symbols` 포트가 아예 사라져(None) LogicNode 가 그 조건을
22265
+ 'symbol-bearing' 이 아니라 'boolean-gate' 로 재분류한다 — 명시적 빈 배열이
22266
+ 교집합을 0 으로 만들던 안전장치가 사라진다.
22267
+ - 주문 노드의 `result` 행이 0건이 되어 "오늘 주문 없음" 원장 행이 증발한다
22268
+ (`NewOrderNodeExecutor.execute` 는 어떤 경로로 끝나든 `result: [row]` 를
22269
+ 한 행 보장한다 — `tests/test_order_result_port.py`).
22270
+
22271
+ ⚠️ ``resilience.fallback.mode=skip`` 의 ``_skipped`` 와는 **다른 사건**이다 —
22272
+ 저 쪽은 노드가 실행됐다가 예외를 내고 재시도까지 소진한 뒤 삼켜진 경우이고,
22273
+ 이 쪽은 반복 대상이 없어 **처음부터 호출조차 하지 않은** 경우다. 소비자가 둘을
22274
+ 구분할 수 있게 키를 분리한다(`skipped_by="no_upstream_signal"`).
22275
+ """
22276
+ payload: Dict[str, Any] = {}
22277
+ for port_name, port_type in _declared_output_ports(node_type):
22278
+ payload[port_name] = _empty_port_value(port_type)
22279
+
22280
+ message = "No trading signal today (upstream condition passed 0 symbols)."
22281
+ if node_type.endswith("OrderNode"):
22282
+ # 주문 노드 소비자(원장/UI/챗봇)는 order_result 와 `result` 행을 본다.
22283
+ order_result = {
22284
+ "success": False,
22285
+ "error": "No order to submit",
22286
+ "reason": EmptyOrderReason.NO_SIGNAL.value,
22287
+ "message": message,
22288
+ "detail": detail,
22289
+ "skipped_by": "no_upstream_signal",
22290
+ }
22291
+ payload["order_result"] = order_result
22292
+ payload["order_id"] = ""
22293
+ # execute() 가 만드는 행과 같은 모양 — 행이 0건이면 "오늘 주문 없음" 이
22294
+ # 화면에서 통째로 사라진다(가장 흔한 정상 케이스만 안 보이게 된다).
22295
+ payload["result"] = [{"order_id": "", **dict(order_result)}]
22296
+ elif "result" not in payload:
22297
+ # 선언을 못 찾은 노드(커뮤니티/레거시)용 최소 호환 키.
22298
+ payload["result"] = []
22299
+
22300
+ payload.update({
22301
+ "reason": EmptyOrderReason.NO_SIGNAL.value,
22302
+ "message": message,
22303
+ "detail": detail,
22304
+ "skipped_by": "no_upstream_signal",
22305
+ "skipped_source_node_id": source_node_id,
22306
+ })
22307
+ return payload
22308
+
21694
22309
  def _should_auto_iterate(
21695
22310
  self,
21696
22311
  node_type: str,
@@ -21766,53 +22381,60 @@ class WorkflowJob:
21766
22381
 
21767
22382
  _safe_print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
21768
22383
 
21769
- for idx, current_item in enumerate(items):
21770
- # === item, index, total을 ExecutionContext에 설정 ===
21771
- self.context.set_iteration_context(current_item, idx, total)
21772
-
21773
- # config 내 표현식 평가 ({{ item.xxx }}, {{ index }} 등)
21774
- item_config = self._resolve_config_expressions(config, node_id)
21775
-
21776
- # 방어선 2: 반복 중인데 복수 포트가 **전체 배열로 재평가**됐으면 경고 + (모의 실행
21777
- # 에서만) 현재 아이템으로 좁힌다 — MarketDataNodeExecutor 의 iteration_item 가드를
21778
- # 일반화한 것. 실전은 경고만(기존 워크플로우 의미 보존; 3.1.6 적대 검증 후 재결정).
21779
- item_config = self._guard_whole_array_reevaluation(
21780
- node_id, node.node_type, item_config, current_item, total,
21781
- )
22384
+ # 🔴 반복 컨텍스트 정리는 **finally 여야 한다.** 종전엔 for 루프 뒤 평문이라,
22385
+ # 루프 안의 `_resolve_config_expressions` / `_guard_whole_array_reevaluation`
22386
+ # (per-item try 밖)이 던지면 정리가 건너뛰어졌다. 그러면 다음에 실행되는
22387
+ # 주문 노드가 **앞 노드가 순회하던 항목**을 자기 반복 항목으로 읽고, 엉뚱한
22388
+ # "반복 항목이 str 이라…" 사유로 정상 무신호를 노드 실패로 뒤집는다.
22389
+ try:
22390
+ for idx, current_item in enumerate(items):
22391
+ # === item, index, total을 ExecutionContext에 설정 ===
22392
+ self.context.set_iteration_context(current_item, idx, total)
22393
+
22394
+ # config 내 표현식 평가 ({{ item.xxx }}, {{ index }} 등)
22395
+ item_config = self._resolve_config_expressions(config, node_id)
22396
+
22397
+ # 방어선 2: 반복 중인데 복수 포트가 **전체 배열로 재평가**됐으면 경고 + (모의 실행
22398
+ # 에서만) 현재 아이템으로 좁힌다 — MarketDataNodeExecutor 의 iteration_item 가드를
22399
+ # 일반화한 것. 실전은 경고만(기존 워크플로우 의미 보존; 3.1.6 적대 검증 후 재결정).
22400
+ item_config = self._guard_whole_array_reevaluation(
22401
+ node_id, node.node_type, item_config, current_item, total,
22402
+ )
21782
22403
 
21783
- # 진행 상황 로그
21784
- item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
21785
- _safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
21786
- self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
22404
+ # 진행 상황 로그
22405
+ item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
22406
+ _safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
22407
+ self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
21787
22408
 
21788
- # A-3: per-item spacing for order / external-API nodes
21789
- # _rate_limit ClassVar가 있는 노드(주문, HTTP 등)에 한해 min_interval_sec
21790
- # 만큼 간격을 보장한다. skip이 아니라 sleep → 모든 N 아이템이 실행됨.
21791
- # rate-limit이 없는 순수 데이터/계산 노드는 영향 없음 (하위 호환).
21792
- await self._auto_iterate_pacing_sleep(node_id, node.node_type)
22409
+ # A-3: per-item spacing for order / external-API nodes
22410
+ # _rate_limit ClassVar가 있는 노드(주문, HTTP 등)에 한해 min_interval_sec
22411
+ # 만큼 간격을 보장한다. skip이 아니라 sleep → 모든 N 아이템이 실행됨.
22412
+ # rate-limit이 없는 순수 데이터/계산 노드는 영향 없음 (하위 호환).
22413
+ await self._auto_iterate_pacing_sleep(node_id, node.node_type)
21793
22414
 
21794
- try:
21795
- outputs = await self.executor.execute_node(
21796
- node_id=node_id,
21797
- node_type=node.node_type,
21798
- config=item_config,
21799
- context=self.context,
21800
- plugin=node.plugin,
21801
- fields=node.fields,
21802
- workflow=self.workflow,
21803
- order_iteration_index=idx,
21804
- )
21805
- all_results.append(outputs)
21806
- except Exception as e:
21807
- self.context.log("warning", f"Auto-iterate [{idx+1}/{total}] failed: {e}", node_id)
21808
- # continue_on_error: 기본적으로 계속 진행
21809
- all_results.append({"error": str(e), "item": current_item})
21810
- finally:
21811
- # per-item 실행 완료 후 spacing 타임스탬프 갱신
21812
- self._auto_iterate_mark_executed(node_id)
22415
+ try:
22416
+ outputs = await self.executor.execute_node(
22417
+ node_id=node_id,
22418
+ node_type=node.node_type,
22419
+ config=item_config,
22420
+ context=self.context,
22421
+ plugin=node.plugin,
22422
+ fields=node.fields,
22423
+ workflow=self.workflow,
22424
+ order_iteration_index=idx,
22425
+ )
22426
+ all_results.append(outputs)
22427
+ except Exception as e:
22428
+ self.context.log("warning", f"Auto-iterate [{idx+1}/{total}] failed: {e}", node_id)
22429
+ # continue_on_error: 기본적으로 계속 진행
22430
+ all_results.append({"error": str(e), "item": current_item})
22431
+ finally:
22432
+ # per-item 실행 완료 후 spacing 타임스탬프 갱신
22433
+ self._auto_iterate_mark_executed(node_id)
21813
22434
 
21814
- # === 반복 종료 후 컨텍스트 정리 ===
21815
- self.context.clear_iteration_context()
22435
+ finally:
22436
+ # === 반복 종료 후 컨텍스트 정리 (예외로 빠져나가도 반드시) ===
22437
+ self.context.clear_iteration_context()
21816
22438
 
21817
22439
  # 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
21818
22440
  merged = self._merge_iterate_results(all_results)
@@ -21890,10 +22512,13 @@ class WorkflowJob:
21890
22512
  # **마지막 1회만 살아남았다**(실측: 28 의 logic 이 5종목 중 1건만 받음).
21891
22513
  # `orders` 도 같은 결함이었다 — PositionSizingNode 를 종목별로 반복하면 canonical
21892
22514
  # `orders` 가 마지막 1건만 남았다(`order` 단수 alias 만 살아남는 셈).
22515
+ # `blocked_orders` — 입력 미해결로 브로커에 아무것도 못 보낸 항목. 단일 필드로
22516
+ # 두면 마지막 항목 값만 남아 "3건 중 2건 차단" 이 소실된다(마지막이 성공하면
22517
+ # 노드가 COMPLETED 로 초록이 된다) — 2026-09-14 사고의 관측성 구멍.
21893
22518
  array_fields = {
21894
22519
  "value", "values", "items", "data", "result", "results",
21895
22520
  "passed_symbols", "failed_symbols", "symbols", "symbol_results",
21896
- "orders",
22521
+ "orders", "blocked_orders",
21897
22522
  }
21898
22523
  # 주문 배열은 (symbol, exchange) 로 중복을 제거한다 — 같은 종목에 주문 객체가
21899
22524
  # 두 번 들어가면 하류 주문 노드가 그만큼 반복된다(실주문 중복 경로).
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.37.1"
8
+ version = "1.37.3"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes