programgarden 1.37.1__tar.gz → 1.37.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-1.37.1 → programgarden-1.37.2}/PKG-INFO +1 -1
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/executor.py +678 -111
- {programgarden-1.37.1 → programgarden-1.37.2}/pyproject.toml +1 -1
- {programgarden-1.37.1 → programgarden-1.37.2}/README.md +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/binding_validator.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/client.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/code_worker.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/context.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/futures_pnl.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/node_runner.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/order_lifecycle.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resolver.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resource/context.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.37.1 → programgarden-1.37.2}/programgarden/validation_recommender.py +0 -0
|
@@ -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 (
|
|
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 > 첫 출력
|
|
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
|
-
|
|
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)
|
|
15417
15527
|
reason, detail = self._diagnose_empty_reason(
|
|
15418
15528
|
order, config, raw_order_expr, context, node_id=node_id
|
|
15419
15529
|
)
|
|
15420
|
-
|
|
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,226 @@ 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
|
+
) -> Optional[Dict[str, Any]]:
|
|
15916
|
+
"""주문 입력이 **채워지지 않아서** 주문을 못 만든 경우의 구체 사유.
|
|
15917
|
+
|
|
15918
|
+
🔴 2026-09-14 prod 실관측:
|
|
15919
|
+
손절 조건 하류의 매도 노드가 통과 종목 dict(`passed_symbols`) 대신 조건 노드의
|
|
15920
|
+
`symbols`(**문자열** 배열)를 순회했다. `{{ item.symbol }}` 은 문자열에 속성이
|
|
15921
|
+
없어 None 으로 풀렸고, 세 종목 모두 symbol/exchange/quantity 가 비어 주문이
|
|
15922
|
+
만들어지지 않았다 — 증권사 주문내역 0건. 그런데 로그에는 "주문할 종목이
|
|
15923
|
+
없습니다" 한 줄뿐이라 사람이 원인을 짚을 수 없었다. 그래서 여기서 **반복 항목의
|
|
15924
|
+
모양까지** 사유에 적는다.
|
|
15925
|
+
|
|
15926
|
+
⚠️ 이 함수는 **사유를 기술할 뿐 분류를 대신하지 않는다.** 반환 dict 의 ``hard``
|
|
15927
|
+
가 True 일 때만 호출부가 NO_SYMBOL 로 승격한다. "오늘 신호 없음"(상류가 정상
|
|
15928
|
+
적으로 빈 결과) 과 "계좌 조회 실패" 판정은 `_diagnose_empty_reason` 의 몫이다 —
|
|
15929
|
+
여기서 가로채면 정상 무신호 날이 매일 "주문 생성 실패" 로 울린다(D2 회귀).
|
|
15930
|
+
|
|
15931
|
+
Returns:
|
|
15932
|
+
{"fields", "kinds", "item_shape", "detail", "message_ko", "hard"} 또는 None.
|
|
15933
|
+
"""
|
|
15934
|
+
# 반복 중인지 먼저 본다. `_iteration_item` 만 보면 ① "반복 안 함" 과 ② "반복 항목이
|
|
15935
|
+
# None" 이 구분되지 않고, ③ 앞 노드의 반복 항목이 정리되지 않고 남아 있으면 무관한
|
|
15936
|
+
# 노드가 남의 항목 모양을 자기 사유로 보고한다. `_iteration_total` 은 반복 중에만
|
|
15937
|
+
# 1 이상이다(`clear_iteration_context` 가 0 으로 되돌린다 — finally 보장).
|
|
15938
|
+
iterating = int(getattr(context, "_iteration_total", 0) or 0) > 0 if context is not None else False
|
|
15939
|
+
item = getattr(context, "_iteration_item", None) if context is not None else None
|
|
15940
|
+
|
|
15941
|
+
# C) 반복 항목 모양 방어 — 항목이 dict 가 아니면 수량·거래소를 담을 자리가 없다.
|
|
15942
|
+
# 문자열 항목을 "종목코드" 로 오해해 그 값으로 주문을 만들면 수량이 없는
|
|
15943
|
+
# 주문이 되거나(거부) 엉뚱한 수량이 실린다. 만들지 않고 사유를 남긴다.
|
|
15944
|
+
item_shape = None
|
|
15945
|
+
if iterating and not isinstance(item, dict):
|
|
15946
|
+
if item is None:
|
|
15947
|
+
item_shape = (
|
|
15948
|
+
"iteration item is None — the upstream array contained an empty entry"
|
|
15949
|
+
)
|
|
15950
|
+
else:
|
|
15951
|
+
item_shape = (
|
|
15952
|
+
f"iteration item is a {type(item).__name__} ({item!r}), not an object with "
|
|
15953
|
+
"symbol/exchange/quantity"
|
|
15954
|
+
)
|
|
15955
|
+
|
|
15956
|
+
if isinstance(order, dict):
|
|
15957
|
+
fields: List[str] = []
|
|
15958
|
+
kinds: Dict[str, str] = {}
|
|
15959
|
+
for key in NewOrderNodeExecutor._required_order_fields(node_type):
|
|
15960
|
+
kind = NewOrderNodeExecutor._unfilled_kind(key, order.get(key))
|
|
15961
|
+
if kind is not None:
|
|
15962
|
+
fields.append(key)
|
|
15963
|
+
kinds[key] = kind
|
|
15964
|
+
if not fields:
|
|
15965
|
+
return None
|
|
15966
|
+
detail = (
|
|
15967
|
+
"Order input was not filled in: "
|
|
15968
|
+
+ ", ".join(f"{f}={order.get(f)!r} ({kinds[f]})" for f in fields)
|
|
15969
|
+
)
|
|
15970
|
+
if item_shape:
|
|
15971
|
+
detail += "; " + item_shape
|
|
15972
|
+
else:
|
|
15973
|
+
# dict 가 아닌 주문 입력. 상류가 **정상적으로** 빈 결과를 낸 경우(예: 사이징이
|
|
15974
|
+
# orders=[] + reason=no_signal)까지 여기서 "입력 미해결" 로 가로채면 "오늘 신호
|
|
15975
|
+
# 없음" 이 "설정 누락" 으로 뒤바뀐다(D2 회귀) — 그래서 **반복 항목의 모양이
|
|
15976
|
+
# 실제로 틀렸을 때만** 진단한다. 그 외는 None 을 돌려 기존 분류로 넘긴다.
|
|
15977
|
+
if item_shape is None:
|
|
15978
|
+
return None
|
|
15979
|
+
fields = list(NewOrderNodeExecutor._required_order_fields(node_type))
|
|
15980
|
+
kinds = {f: "missing" for f in fields}
|
|
15981
|
+
detail = (
|
|
15982
|
+
f"Order input is a {type(order).__name__} ({order!r}), not an order object "
|
|
15983
|
+
"with symbol/exchange/quantity; " + item_shape
|
|
15984
|
+
)
|
|
15985
|
+
|
|
15986
|
+
# 🔴 "확실한 배선 고장" 인가. 이것만 NO_SYMBOL 승격 + 사용자 알림 대상이다.
|
|
15987
|
+
# - 반복 항목 모양이 틀림(이번 사고)
|
|
15988
|
+
# - 미해석 템플릿 리터럴이 남음(C23 가 거부한 그 값)
|
|
15989
|
+
# - 수량이 숫자가 아님
|
|
15990
|
+
# 수량이 0/음수인 것만으로는 승격하지 않는다 — 자금 부족·소수점 잔량 등
|
|
15991
|
+
# **정상적으로 오늘 못 사는 날**이 있고, 그건 `_diagnose_empty_reason` 이
|
|
15992
|
+
# no_signal / fractional_only 로 더 정확히 분류한다.
|
|
15993
|
+
hard = item_shape is not None or any(
|
|
15994
|
+
kind in ("unresolved_template", "invalid") for kind in kinds.values()
|
|
15995
|
+
)
|
|
15996
|
+
|
|
15997
|
+
labels = NewOrderNodeExecutor._ORDER_FIELD_LABELS
|
|
15998
|
+
kind_ko = {
|
|
15999
|
+
"missing": "비어 있음",
|
|
16000
|
+
"unresolved_template": "바인딩이 풀리지 않은 템플릿 그대로",
|
|
16001
|
+
"invalid": "숫자가 아님",
|
|
16002
|
+
"zero": "0 이하",
|
|
16003
|
+
}
|
|
16004
|
+
ko_fields = ", ".join(
|
|
16005
|
+
f"{labels.get(f, f)}({f})={kind_ko.get(kinds[f], kinds[f])}" for f in fields
|
|
16006
|
+
)
|
|
16007
|
+
message_ko = (
|
|
16008
|
+
f"주문 입력이 채워지지 않아 주문을 만들지 않았습니다 — 문제 항목: {ko_fields}."
|
|
16009
|
+
)
|
|
16010
|
+
if item_shape is not None:
|
|
16011
|
+
shape_ko = (
|
|
16012
|
+
"반복 항목이 비어 있습니다(None)."
|
|
16013
|
+
if item is None
|
|
16014
|
+
else f"반복 항목이 {type(item).__name__} 값({item!r})이라 수량·거래소를 담고 있지 않습니다."
|
|
16015
|
+
)
|
|
16016
|
+
message_ko += (
|
|
16017
|
+
f" {shape_ko} 상류 조건 노드의 `symbols`(평가 대상 전체 문자열)가 아니라 "
|
|
16018
|
+
"`passed_symbols`(통과 종목 객체)를 반복하도록 연결하세요."
|
|
16019
|
+
)
|
|
16020
|
+
else:
|
|
16021
|
+
message_ko += (
|
|
16022
|
+
" 상류 바인딩(`{{ item.* }}` / `{{ nodes.<id>.<port> }}`)이 값을 내지 못했습니다."
|
|
16023
|
+
)
|
|
16024
|
+
|
|
16025
|
+
return {
|
|
16026
|
+
"fields": fields,
|
|
16027
|
+
"kinds": kinds,
|
|
16028
|
+
"item_shape": item_shape,
|
|
16029
|
+
"detail": detail,
|
|
16030
|
+
"message_ko": message_ko,
|
|
16031
|
+
"hard": hard,
|
|
16032
|
+
}
|
|
16033
|
+
|
|
16034
|
+
@staticmethod
|
|
16035
|
+
async def _notify_unresolved_order_input(
|
|
16036
|
+
context: "ExecutionContext",
|
|
16037
|
+
node_id: str,
|
|
16038
|
+
node_type: str,
|
|
16039
|
+
unfilled: Dict[str, Any],
|
|
16040
|
+
) -> None:
|
|
16041
|
+
"""입력 미해결로 주문을 못 만든 사실을 사용자 알림 경로에도 올린다.
|
|
16042
|
+
|
|
16043
|
+
ORDER_REJECTED 를 쓰되 `blocked_before_broker=True` / `reason` 으로 브로커 거부와
|
|
16044
|
+
구분한다(브로커 거부는 `rsp_cd` 를 들고 온다). 알림 실패가 주문 경로를 죽이면
|
|
16045
|
+
안 되므로 예외는 삼킨다.
|
|
16046
|
+
|
|
16047
|
+
🔴 **이 알림은 2026-09-14 현재 실배포에서 소비자가 없다 — 이것만 믿지 말 것.**
|
|
16048
|
+
`ExecutionContext.notify_notification` 은 `on_notification` 을 구현한 리스너
|
|
16049
|
+
에게만 전달하는데, pg-worker `SSEListener` 도 트레이앱 `EventForwarder` 도
|
|
16050
|
+
구현하지 않아 `BaseExecutionListener.on_notification`(pass)에서 끝난다.
|
|
16051
|
+
그래서 이 사건이 **실제로 사람에게 도달하는 경로는 아래 셋**이다:
|
|
16052
|
+
1. `logger.warning` + `_safe_print` → 파드 stdout(`kubectl logs | grep
|
|
16053
|
+
unresolved_order_input`)
|
|
16054
|
+
2. 노드 출력 `order_result.skipped_by/unfilled_fields/message_ko`
|
|
16055
|
+
+ 병합에서 살아남는 `blocked_orders` 배열
|
|
16056
|
+
3. `_order_failure_from_outputs` → node_state FAILED 의 `error` 문자열
|
|
16057
|
+
(message_ko 를 앞에 싣는다) → 대시보드
|
|
16058
|
+
pg-worker/트레이앱에 `on_notification` 이 배선되면 이 주석을 지운다.
|
|
16059
|
+
"""
|
|
16060
|
+
try:
|
|
16061
|
+
await context.send_notification(
|
|
16062
|
+
category=NotificationCategory.ORDER_REJECTED,
|
|
16063
|
+
severity=NotificationSeverity.WARNING,
|
|
16064
|
+
title="주문 생성 실패 — 입력이 비어 있습니다",
|
|
16065
|
+
message=unfilled["message_ko"],
|
|
16066
|
+
node_id=node_id,
|
|
16067
|
+
node_type=node_type,
|
|
16068
|
+
data={
|
|
16069
|
+
"blocked_before_broker": True,
|
|
16070
|
+
"reason": "unresolved_order_input",
|
|
16071
|
+
"unfilled_fields": unfilled["fields"],
|
|
16072
|
+
"unfilled_kinds": unfilled.get("kinds"),
|
|
16073
|
+
"item_shape": unfilled["item_shape"],
|
|
16074
|
+
"detail": unfilled["detail"],
|
|
16075
|
+
},
|
|
16076
|
+
)
|
|
16077
|
+
except Exception as exc: # pragma: no cover - 알림 실패는 주문 경로를 죽이지 않는다
|
|
16078
|
+
logger.warning("unresolved order input notification failed: %s", exc)
|
|
16079
|
+
|
|
15702
16080
|
def _diagnose_empty_reason(
|
|
15703
16081
|
self,
|
|
15704
16082
|
order: Any,
|
|
@@ -21256,74 +21634,66 @@ class WorkflowJob:
|
|
|
21256
21634
|
try:
|
|
21257
21635
|
# === 자동 iterate 체크 ===
|
|
21258
21636
|
# 입력 데이터가 배열이고, 노드가 단일 아이템을 기대하면 자동으로 각 아이템마다 실행
|
|
21259
|
-
input_data =
|
|
21260
|
-
|
|
21261
|
-
|
|
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
|
|
21637
|
+
input_data, iterate_source, iterate_source_node_id = (
|
|
21638
|
+
self._select_auto_iterate_source(node_id)
|
|
21639
|
+
)
|
|
21320
21640
|
|
|
21321
21641
|
should_iterate, port_name, items = self._should_auto_iterate(
|
|
21322
21642
|
node.node_type, input_data, config,
|
|
21323
21643
|
)
|
|
21324
21644
|
|
|
21325
|
-
|
|
21326
|
-
|
|
21645
|
+
# 🔴 조건 게이트가 0건 통과시켰는데(= passed_symbols 가 **정본이고
|
|
21646
|
+
# 비었다**) 이 노드가 아이템 바인딩(`{{ item.* }}`)을 쓰거나 주문 노드면,
|
|
21647
|
+
# 한 번 돌려 봐야 아이템 바인딩이 전부 None 으로 풀려 "종목 없는 주문"
|
|
21648
|
+
# 한 건을 만들 뿐이고, 리터럴 주문이면 **조건을 통과하지 못한 종목에
|
|
21649
|
+
# 실주문**이 나간다. 실행 자체를 건너뛰고 사유를 남긴다 — 브로커 TR 0.
|
|
21650
|
+
# (2026-09-14 prod 사고의 안전한 쌍둥이: 여기서 `symbols`(평가 대상 전체)
|
|
21651
|
+
# 로 흘러내리면 **손절에 걸리지도 않은 종목까지 전량 매도**된다.)
|
|
21652
|
+
#
|
|
21653
|
+
# ⚠️ 아이템 바인딩 조건을 **떼면 안 된다** — 조건 노드를 게이트로만 물고
|
|
21654
|
+
# `{{ nodes.cond.symbols }}` 로 전체를 소비하는 알림/집계/표시 노드까지
|
|
21655
|
+
# 침묵한다. "통과 0건이면 아무것도 안 하는 게 정답" 은 **주문 노드**
|
|
21656
|
+
# 이야기이고, 상태 보고 노드는 그날도 말을 해야 한다(요구사항 4).
|
|
21657
|
+
skip_reason = None
|
|
21658
|
+
if (
|
|
21659
|
+
not should_iterate
|
|
21660
|
+
and iterate_source == self.ITERATE_SOURCE_PASSED_SYMBOLS
|
|
21661
|
+
and isinstance(input_data, list)
|
|
21662
|
+
and not input_data
|
|
21663
|
+
and (
|
|
21664
|
+
self._references_iteration_item(config)
|
|
21665
|
+
or node.node_type.endswith("OrderNode")
|
|
21666
|
+
)
|
|
21667
|
+
):
|
|
21668
|
+
skip_reason = (
|
|
21669
|
+
f"upstream condition gate '{iterate_source_node_id}' passed 0 symbols "
|
|
21670
|
+
"(passed_symbols=[]) — skipped without executing, so no broker "
|
|
21671
|
+
"request was made"
|
|
21672
|
+
)
|
|
21673
|
+
|
|
21674
|
+
auto_iterated = (
|
|
21675
|
+
should_iterate and node_id not in branch_nodes and skip_reason is None
|
|
21676
|
+
)
|
|
21677
|
+
if skip_reason is not None:
|
|
21678
|
+
# 상류 노드 id 를 반드시 적는다 — 이게 없으면 "왜 몇 주째 주문이
|
|
21679
|
+
# 없는가" 를 로그만 보고 되짚을 수 없다. `no_upstream_signal` 은
|
|
21680
|
+
# grep 용 고정 토큰(`kubectl logs | grep no_upstream_signal`);
|
|
21681
|
+
# `context.log` 는 파드 stdout 에 안 찍히므로 _safe_print 를 병기한다.
|
|
21682
|
+
self.context.log(
|
|
21683
|
+
"info",
|
|
21684
|
+
f"{node.node_type}: 상류 조건 노드 '{iterate_source_node_id}' 의 통과 "
|
|
21685
|
+
f"종목이 0건이라 실행을 건너뜁니다 (no_upstream_signal — 주문/조회 "
|
|
21686
|
+
f"요청 없음).",
|
|
21687
|
+
node_id,
|
|
21688
|
+
)
|
|
21689
|
+
_safe_print(
|
|
21690
|
+
f" ⏭️ no_upstream_signal: {node_id} ({node.node_type}) "
|
|
21691
|
+
f"← {iterate_source_node_id}.passed_symbols=0 — not executed"
|
|
21692
|
+
)
|
|
21693
|
+
outputs = self._no_signal_skip_outputs(
|
|
21694
|
+
node.node_type, skip_reason, iterate_source_node_id,
|
|
21695
|
+
)
|
|
21696
|
+
elif auto_iterated:
|
|
21327
21697
|
# 자동 iterate 실행 (SplitNode 브랜치가 아닌 경우에만)
|
|
21328
21698
|
outputs = await self._execute_with_auto_iterate(
|
|
21329
21699
|
node_id=node_id,
|
|
@@ -21691,6 +22061,193 @@ class WorkflowJob:
|
|
|
21691
22061
|
# 리터럴 목록이거나(이미 평가된 배열 포함) 전체 바인딩 문자열
|
|
21692
22062
|
return isinstance(value, (list, str, dict))
|
|
21693
22063
|
|
|
22064
|
+
# auto-iterate 소스 라벨 — 어느 우선순위 단계가 반복 대상을 골랐는지.
|
|
22065
|
+
# `_select_auto_iterate_source` 의 두 번째 반환값이며, 호출부가 "조건 게이트의 통과
|
|
22066
|
+
# 목록인가" 를 되물을 수 있게 한다(0건 통과 시 실행 자체를 건너뛰는 판정에 쓰인다).
|
|
22067
|
+
ITERATE_SOURCE_NONE = ""
|
|
22068
|
+
ITERATE_SOURCE_EXPLICIT = "explicit"
|
|
22069
|
+
ITERATE_SOURCE_PASSED_SYMBOLS = "passed_symbols"
|
|
22070
|
+
ITERATE_SOURCE_SYMBOLS = "symbols"
|
|
22071
|
+
ITERATE_SOURCE_FALLBACK = "fallback"
|
|
22072
|
+
|
|
22073
|
+
# IfNode 분기 포트 — 데이터가 아니라 라우팅 의미라 명시 from_port 로 쳐 주지 않는다.
|
|
22074
|
+
_NON_DATA_FROM_PORTS = ("output", "true", "false", "result")
|
|
22075
|
+
|
|
22076
|
+
def _select_auto_iterate_source(self, node_id: str) -> Tuple[Any, str, str]:
|
|
22077
|
+
"""이 노드의 auto-iterate 반복 대상 / 출처 라벨 / 출처 노드 id 를 고른다.
|
|
22078
|
+
|
|
22079
|
+
incoming 엣지를 **전부** 훑어 우선순위로 고른다. 예전엔 첫 매칭 엣지에서 무조건
|
|
22080
|
+
break 해서 **엣지 선언 순서가 소스를 결정**했다:
|
|
22081
|
+
- 예제 16/28 은 account 엣지가 먼저라 sizing 이 **계좌 보유종목**을 순회했다
|
|
22082
|
+
(실측: 워크플로우 어디에도 없는 AUID 를 매수 후보로 처리 — 잔고가 충분했다면
|
|
22083
|
+
보유종목에 실제 주문이 나갔다).
|
|
22084
|
+
- 예제 28 은 `logic.passed_symbols` 를 올바로 명시했는데도 앞선 account 엣지의
|
|
22085
|
+
break 에 가려 explicit 분기까지 도달조차 못 했다.
|
|
22086
|
+
|
|
22087
|
+
🔴 우선순위: **명시 from_port > symbols(+passed_symbols 승격) > 소스 노드 첫 출력.**
|
|
22088
|
+
|
|
22089
|
+
2026-09-14 prod 실관측 사고로 가운데 단계에 "승격" 이 붙었다. ConditionNode 는
|
|
22090
|
+
통과 여부와 무관하게 ``symbols``(**평가 대상 전체**의 종목코드 **문자열** 배열)를
|
|
22091
|
+
함께 내보낸다. 그래서 손절 조건(StopLoss) 하류의 매도 주문 노드가
|
|
22092
|
+
|
|
22093
|
+
- 통과 종목 dict(`passed_symbols`: symbol/exchange/quantity/close_side) 가 아니라
|
|
22094
|
+
- 문자열 3개(`symbols`: ["NIO", "MARA", "ZOMDF"]) 를 순회했고,
|
|
22095
|
+
|
|
22096
|
+
`{{ item.symbol }}` / `{{ item.exchange }}` / `{{ item.quantity }}` 가 전부 None 으로
|
|
22097
|
+
풀려 **증권사 주문내역 0건** — 손절 -8% 를 10.5% 하락에서도 못 냈다(9/11~9/14).
|
|
22098
|
+
그리고 이 항목이 dict 였다면 반대로 **손절에 걸리지 않은 MARA 까지 전량 매도**된다.
|
|
22099
|
+
|
|
22100
|
+
⚠️ **승격은 "선택된 소스 노드 안에서" 만 한다 — 엣지를 가로지르지 않는다.**
|
|
22101
|
+
passed_symbols 를 독립 단계로 올려 symbols 단계보다 **먼저** 반환했더니 두 가지가
|
|
22102
|
+
깨졌다:
|
|
22103
|
+
1) 출하 예제 47/48 처럼 `hist -> touch_check` 와 `sr_detect(ConditionNode) ->
|
|
22104
|
+
touch_check` 가 함께 물린 배선에서, 진짜 반복 소스인 hist 의 시계열 대신
|
|
22105
|
+
조건 노드의 통과 목록을 순회해 `items.from` 이 0행이 되고 전략이 통째로
|
|
22106
|
+
신호 0 이 됐다(조용한 거래 중단).
|
|
22107
|
+
2) LogicNode 는 `symbols` 포트가 없어 종전엔 첫 출력 `result`(bool)라 **반복이
|
|
22108
|
+
아예 안 걸렸다**. passed_symbols 를 무조건 먼저 집으면 `logic -> order`
|
|
22109
|
+
(from_port 없음, 리터럴 주문) 배선이 1회 → N회가 되어 **같은 주문이 N번**
|
|
22110
|
+
브로커로 나간다.
|
|
22111
|
+
그래서 승격 조건은 "이 노드가 반복하기로 고른 바로 그 소스 노드가
|
|
22112
|
+
passed_symbols 도 함께 낸다" 이다 — 그게 사고를 낸 ConditionNode 의 모양이고,
|
|
22113
|
+
예제 47/48(hist 가 소스) 도 LogicNode(symbols 없음) 도 여기에 걸리지 않는다.
|
|
22114
|
+
|
|
22115
|
+
승격되면 **빈 배열이라도 정본**이다 — 절대 `symbols`(평가 대상 전체)로 흘러내리지
|
|
22116
|
+
않는다. 그게 "통과 0건이면 아무것도 하지 않는다" 를 보장하는 지점이다.
|
|
22117
|
+
|
|
22118
|
+
Returns:
|
|
22119
|
+
(input_data, source_label, source_node_id)
|
|
22120
|
+
"""
|
|
22121
|
+
explicit_data = None
|
|
22122
|
+
explicit_port_name = ""
|
|
22123
|
+
explicit_node_id = ""
|
|
22124
|
+
symbols_data = None
|
|
22125
|
+
symbols_node_id = ""
|
|
22126
|
+
fallback_data = None
|
|
22127
|
+
fallback_node_id = ""
|
|
22128
|
+
|
|
22129
|
+
for edge in self.workflow.edges:
|
|
22130
|
+
if edge.to_node_id != node_id:
|
|
22131
|
+
continue
|
|
22132
|
+
|
|
22133
|
+
# 1순위: 명시적 from_port (예: ExclusionListNode.filtered,
|
|
22134
|
+
# LogicNode.passed_symbols). IfNode 분기 포트(true/false/result)는
|
|
22135
|
+
# 라우팅 의미라 소스에서 제외.
|
|
22136
|
+
explicit_port = getattr(edge, "from_port", None)
|
|
22137
|
+
if explicit_port and explicit_port not in self._NON_DATA_FROM_PORTS:
|
|
22138
|
+
port_data = self.context.get_output(edge.from_node_id, explicit_port)
|
|
22139
|
+
if port_data is not None and explicit_data is None:
|
|
22140
|
+
explicit_data = port_data
|
|
22141
|
+
explicit_port_name = explicit_port
|
|
22142
|
+
explicit_node_id = edge.from_node_id
|
|
22143
|
+
|
|
22144
|
+
# 2순위: symbols 포트 (Watchlist/MarketUniverse/SymbolFilter/Condition 등)
|
|
22145
|
+
if symbols_data is None:
|
|
22146
|
+
port_symbols = self.context.get_output(edge.from_node_id, "symbols")
|
|
22147
|
+
# symbols가 문자열 배열이면 (merge 후) value 포트로 폴백
|
|
22148
|
+
# - WatchlistNode symbols: [{exchange, symbol}, ...] → dict 배열 → 그대로 사용
|
|
22149
|
+
# - HistoricalDataNode merged symbols: ["TSLA"] → string 배열 → value 포트로 전환
|
|
22150
|
+
if (isinstance(port_symbols, list) and port_symbols
|
|
22151
|
+
and not isinstance(port_symbols[0], dict)):
|
|
22152
|
+
value_data = self.context.get_output(edge.from_node_id, "value")
|
|
22153
|
+
if value_data is not None:
|
|
22154
|
+
if isinstance(value_data, list):
|
|
22155
|
+
port_symbols = value_data
|
|
22156
|
+
elif isinstance(value_data, dict):
|
|
22157
|
+
port_symbols = [value_data]
|
|
22158
|
+
if port_symbols is not None:
|
|
22159
|
+
symbols_data = port_symbols
|
|
22160
|
+
symbols_node_id = edge.from_node_id
|
|
22161
|
+
|
|
22162
|
+
# 3순위: 소스 노드의 첫 출력. 단 계좌 노드는 제외한다 —
|
|
22163
|
+
# 첫 포트가 `positions`(보유잔고)라 매수 후보로 오인된다.
|
|
22164
|
+
if fallback_data is None:
|
|
22165
|
+
from_node = self.workflow.nodes.get(edge.from_node_id)
|
|
22166
|
+
from_type = getattr(from_node, "node_type", None) if from_node else None
|
|
22167
|
+
if from_type not in self.NO_ITERATE_SOURCE_NODE_TYPES:
|
|
22168
|
+
candidate = self.context.get_output(edge.from_node_id, None)
|
|
22169
|
+
if candidate is not None:
|
|
22170
|
+
fallback_data = candidate
|
|
22171
|
+
fallback_node_id = edge.from_node_id
|
|
22172
|
+
|
|
22173
|
+
if explicit_data is not None:
|
|
22174
|
+
# 명시적으로 `passed_symbols` 를 가리킨 배선도 조건 게이트다 — 0건일 때
|
|
22175
|
+
# 하류를 건너뛰는 판정이 똑같이 걸려야 한다(라벨이 그 스위치다).
|
|
22176
|
+
label = (
|
|
22177
|
+
self.ITERATE_SOURCE_PASSED_SYMBOLS
|
|
22178
|
+
if explicit_port_name == "passed_symbols"
|
|
22179
|
+
else self.ITERATE_SOURCE_EXPLICIT
|
|
22180
|
+
)
|
|
22181
|
+
return explicit_data, label, explicit_node_id
|
|
22182
|
+
if symbols_data is not None:
|
|
22183
|
+
# 🔴 승격: 고른 소스 노드가 `passed_symbols` 도 낸다면(= ConditionNode 모양)
|
|
22184
|
+
# 그게 정본이다. 키가 있으면 **빈 배열이라도** 여기서 끝난다.
|
|
22185
|
+
port_passed = self.context.get_output(symbols_node_id, "passed_symbols")
|
|
22186
|
+
if isinstance(port_passed, list):
|
|
22187
|
+
return port_passed, self.ITERATE_SOURCE_PASSED_SYMBOLS, symbols_node_id
|
|
22188
|
+
return symbols_data, self.ITERATE_SOURCE_SYMBOLS, symbols_node_id
|
|
22189
|
+
if fallback_data is not None:
|
|
22190
|
+
return fallback_data, self.ITERATE_SOURCE_FALLBACK, fallback_node_id
|
|
22191
|
+
return None, self.ITERATE_SOURCE_NONE, ""
|
|
22192
|
+
|
|
22193
|
+
def _no_signal_skip_outputs(
|
|
22194
|
+
self,
|
|
22195
|
+
node_type: str,
|
|
22196
|
+
detail: str,
|
|
22197
|
+
source_node_id: str = "",
|
|
22198
|
+
) -> Dict[str, Any]:
|
|
22199
|
+
"""상류 조건 통과 0건으로 **실행하지 않은** 노드의 출력.
|
|
22200
|
+
|
|
22201
|
+
🔴 **출력을 교체하지 않고, 선언된 포트를 빈 값으로 채운다.** 종전 구현은
|
|
22202
|
+
`{result: [], reason, message, detail, skipped_by}` 한 봉투로 갈아치웠는데,
|
|
22203
|
+
그러면 하류가 조용히 오작동한다:
|
|
22204
|
+
- ConditionNode 의 `result` 가 bool 이 아니라 `[]` 가 되어
|
|
22205
|
+
`{{ nodes.cond.result }} == true` 비교가 깨진다.
|
|
22206
|
+
- `passed_symbols` 포트가 아예 사라져(None) LogicNode 가 그 조건을
|
|
22207
|
+
'symbol-bearing' 이 아니라 'boolean-gate' 로 재분류한다 — 명시적 빈 배열이
|
|
22208
|
+
교집합을 0 으로 만들던 안전장치가 사라진다.
|
|
22209
|
+
- 주문 노드의 `result` 행이 0건이 되어 "오늘 주문 없음" 원장 행이 증발한다
|
|
22210
|
+
(`NewOrderNodeExecutor.execute` 는 어떤 경로로 끝나든 `result: [row]` 를
|
|
22211
|
+
한 행 보장한다 — `tests/test_order_result_port.py`).
|
|
22212
|
+
|
|
22213
|
+
⚠️ ``resilience.fallback.mode=skip`` 의 ``_skipped`` 와는 **다른 사건**이다 —
|
|
22214
|
+
저 쪽은 노드가 실행됐다가 예외를 내고 재시도까지 소진한 뒤 삼켜진 경우이고,
|
|
22215
|
+
이 쪽은 반복 대상이 없어 **처음부터 호출조차 하지 않은** 경우다. 소비자가 둘을
|
|
22216
|
+
구분할 수 있게 키를 분리한다(`skipped_by="no_upstream_signal"`).
|
|
22217
|
+
"""
|
|
22218
|
+
payload: Dict[str, Any] = {}
|
|
22219
|
+
for port_name, port_type in _declared_output_ports(node_type):
|
|
22220
|
+
payload[port_name] = _empty_port_value(port_type)
|
|
22221
|
+
|
|
22222
|
+
message = "No trading signal today (upstream condition passed 0 symbols)."
|
|
22223
|
+
if node_type.endswith("OrderNode"):
|
|
22224
|
+
# 주문 노드 소비자(원장/UI/챗봇)는 order_result 와 `result` 행을 본다.
|
|
22225
|
+
order_result = {
|
|
22226
|
+
"success": False,
|
|
22227
|
+
"error": "No order to submit",
|
|
22228
|
+
"reason": EmptyOrderReason.NO_SIGNAL.value,
|
|
22229
|
+
"message": message,
|
|
22230
|
+
"detail": detail,
|
|
22231
|
+
"skipped_by": "no_upstream_signal",
|
|
22232
|
+
}
|
|
22233
|
+
payload["order_result"] = order_result
|
|
22234
|
+
payload["order_id"] = ""
|
|
22235
|
+
# execute() 가 만드는 행과 같은 모양 — 행이 0건이면 "오늘 주문 없음" 이
|
|
22236
|
+
# 화면에서 통째로 사라진다(가장 흔한 정상 케이스만 안 보이게 된다).
|
|
22237
|
+
payload["result"] = [{"order_id": "", **dict(order_result)}]
|
|
22238
|
+
elif "result" not in payload:
|
|
22239
|
+
# 선언을 못 찾은 노드(커뮤니티/레거시)용 최소 호환 키.
|
|
22240
|
+
payload["result"] = []
|
|
22241
|
+
|
|
22242
|
+
payload.update({
|
|
22243
|
+
"reason": EmptyOrderReason.NO_SIGNAL.value,
|
|
22244
|
+
"message": message,
|
|
22245
|
+
"detail": detail,
|
|
22246
|
+
"skipped_by": "no_upstream_signal",
|
|
22247
|
+
"skipped_source_node_id": source_node_id,
|
|
22248
|
+
})
|
|
22249
|
+
return payload
|
|
22250
|
+
|
|
21694
22251
|
def _should_auto_iterate(
|
|
21695
22252
|
self,
|
|
21696
22253
|
node_type: str,
|
|
@@ -21766,53 +22323,60 @@ class WorkflowJob:
|
|
|
21766
22323
|
|
|
21767
22324
|
_safe_print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
|
|
21768
22325
|
|
|
21769
|
-
for
|
|
21770
|
-
|
|
21771
|
-
|
|
21772
|
-
|
|
21773
|
-
|
|
21774
|
-
|
|
21775
|
-
|
|
21776
|
-
|
|
21777
|
-
|
|
21778
|
-
|
|
21779
|
-
|
|
21780
|
-
|
|
21781
|
-
|
|
22326
|
+
# 🔴 반복 컨텍스트 정리는 **finally 여야 한다.** 종전엔 for 루프 뒤 평문이라,
|
|
22327
|
+
# 루프 안의 `_resolve_config_expressions` / `_guard_whole_array_reevaluation`
|
|
22328
|
+
# (per-item try 밖)이 던지면 정리가 건너뛰어졌다. 그러면 다음에 실행되는
|
|
22329
|
+
# 주문 노드가 **앞 노드가 순회하던 항목**을 자기 반복 항목으로 읽고, 엉뚱한
|
|
22330
|
+
# "반복 항목이 str 이라…" 사유로 정상 무신호를 노드 실패로 뒤집는다.
|
|
22331
|
+
try:
|
|
22332
|
+
for idx, current_item in enumerate(items):
|
|
22333
|
+
# === item, index, total을 ExecutionContext에 설정 ===
|
|
22334
|
+
self.context.set_iteration_context(current_item, idx, total)
|
|
22335
|
+
|
|
22336
|
+
# config 내 표현식 평가 ({{ item.xxx }}, {{ index }} 등)
|
|
22337
|
+
item_config = self._resolve_config_expressions(config, node_id)
|
|
22338
|
+
|
|
22339
|
+
# 방어선 2: 반복 중인데 복수 포트가 **전체 배열로 재평가**됐으면 경고 + (모의 실행
|
|
22340
|
+
# 에서만) 현재 아이템으로 좁힌다 — MarketDataNodeExecutor 의 iteration_item 가드를
|
|
22341
|
+
# 일반화한 것. 실전은 경고만(기존 워크플로우 의미 보존; 3.1.6 적대 검증 후 재결정).
|
|
22342
|
+
item_config = self._guard_whole_array_reevaluation(
|
|
22343
|
+
node_id, node.node_type, item_config, current_item, total,
|
|
22344
|
+
)
|
|
21782
22345
|
|
|
21783
|
-
|
|
21784
|
-
|
|
21785
|
-
|
|
21786
|
-
|
|
22346
|
+
# 진행 상황 로그
|
|
22347
|
+
item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
|
|
22348
|
+
_safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
|
|
22349
|
+
self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
|
|
21787
22350
|
|
|
21788
|
-
|
|
21789
|
-
|
|
21790
|
-
|
|
21791
|
-
|
|
21792
|
-
|
|
22351
|
+
# A-3: per-item spacing for order / external-API nodes
|
|
22352
|
+
# _rate_limit ClassVar가 있는 노드(주문, HTTP 등)에 한해 min_interval_sec
|
|
22353
|
+
# 만큼 간격을 보장한다. skip이 아니라 sleep → 모든 N 아이템이 실행됨.
|
|
22354
|
+
# rate-limit이 없는 순수 데이터/계산 노드는 영향 없음 (하위 호환).
|
|
22355
|
+
await self._auto_iterate_pacing_sleep(node_id, node.node_type)
|
|
21793
22356
|
|
|
21794
|
-
|
|
21795
|
-
|
|
21796
|
-
|
|
21797
|
-
|
|
21798
|
-
|
|
21799
|
-
|
|
21800
|
-
|
|
21801
|
-
|
|
21802
|
-
|
|
21803
|
-
|
|
21804
|
-
|
|
21805
|
-
|
|
21806
|
-
|
|
21807
|
-
|
|
21808
|
-
|
|
21809
|
-
|
|
21810
|
-
|
|
21811
|
-
|
|
21812
|
-
|
|
22357
|
+
try:
|
|
22358
|
+
outputs = await self.executor.execute_node(
|
|
22359
|
+
node_id=node_id,
|
|
22360
|
+
node_type=node.node_type,
|
|
22361
|
+
config=item_config,
|
|
22362
|
+
context=self.context,
|
|
22363
|
+
plugin=node.plugin,
|
|
22364
|
+
fields=node.fields,
|
|
22365
|
+
workflow=self.workflow,
|
|
22366
|
+
order_iteration_index=idx,
|
|
22367
|
+
)
|
|
22368
|
+
all_results.append(outputs)
|
|
22369
|
+
except Exception as e:
|
|
22370
|
+
self.context.log("warning", f"Auto-iterate [{idx+1}/{total}] failed: {e}", node_id)
|
|
22371
|
+
# continue_on_error: 기본적으로 계속 진행
|
|
22372
|
+
all_results.append({"error": str(e), "item": current_item})
|
|
22373
|
+
finally:
|
|
22374
|
+
# per-item 실행 완료 후 spacing 타임스탬프 갱신
|
|
22375
|
+
self._auto_iterate_mark_executed(node_id)
|
|
21813
22376
|
|
|
21814
|
-
|
|
21815
|
-
|
|
22377
|
+
finally:
|
|
22378
|
+
# === 반복 종료 후 컨텍스트 정리 (예외로 빠져나가도 반드시) ===
|
|
22379
|
+
self.context.clear_iteration_context()
|
|
21816
22380
|
|
|
21817
22381
|
# 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
|
|
21818
22382
|
merged = self._merge_iterate_results(all_results)
|
|
@@ -21890,10 +22454,13 @@ class WorkflowJob:
|
|
|
21890
22454
|
# **마지막 1회만 살아남았다**(실측: 28 의 logic 이 5종목 중 1건만 받음).
|
|
21891
22455
|
# `orders` 도 같은 결함이었다 — PositionSizingNode 를 종목별로 반복하면 canonical
|
|
21892
22456
|
# `orders` 가 마지막 1건만 남았다(`order` 단수 alias 만 살아남는 셈).
|
|
22457
|
+
# `blocked_orders` — 입력 미해결로 브로커에 아무것도 못 보낸 항목. 단일 필드로
|
|
22458
|
+
# 두면 마지막 항목 값만 남아 "3건 중 2건 차단" 이 소실된다(마지막이 성공하면
|
|
22459
|
+
# 노드가 COMPLETED 로 초록이 된다) — 2026-09-14 사고의 관측성 구멍.
|
|
21893
22460
|
array_fields = {
|
|
21894
22461
|
"value", "values", "items", "data", "result", "results",
|
|
21895
22462
|
"passed_symbols", "failed_symbols", "symbols", "symbol_results",
|
|
21896
|
-
"orders",
|
|
22463
|
+
"orders", "blocked_orders",
|
|
21897
22464
|
}
|
|
21898
22465
|
# 주문 배열은 (symbol, exchange) 로 중복을 제거한다 — 같은 종목에 주문 객체가
|
|
21899
22466
|
# 두 번 들어가면 하류 주문 노드가 그만큼 반복된다(실주문 중복 경로).
|
|
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
|
{programgarden-1.37.1 → programgarden-1.37.2}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.37.1 → programgarden-1.37.2}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|