programgarden 1.24.0__tar.gz → 1.24.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.
Files changed (36) hide show
  1. {programgarden-1.24.0 → programgarden-1.24.2}/PKG-INFO +5 -4
  2. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/executor.py +447 -29
  3. {programgarden-1.24.0 → programgarden-1.24.2}/pyproject.toml +5 -4
  4. {programgarden-1.24.0 → programgarden-1.24.2}/README.md +0 -0
  5. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/__init__.py +0 -0
  6. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/client.py +0 -0
  8. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/context.py +0 -0
  9. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/database/__init__.py +0 -0
  10. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/database/checkpoint_manager.py +0 -0
  11. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/database/query_builder.py +0 -0
  12. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/database/workflow_position_tracker.py +0 -0
  13. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/database/workflow_risk_tracker.py +0 -0
  14. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/deep_fixtures.py +0 -0
  15. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/node_runner.py +0 -0
  16. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/plugin/__init__.py +0 -0
  17. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/plugin/sandbox.py +0 -0
  18. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/providers/__init__.py +0 -0
  19. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/providers/llm_errors.py +0 -0
  20. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/providers/llm_provider.py +0 -0
  21. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/reconnect_handler.py +0 -0
  22. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resolver.py +0 -0
  23. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resource/__init__.py +0 -0
  24. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resource/context.py +0 -0
  25. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resource/limiter.py +0 -0
  26. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resource/monitor.py +0 -0
  27. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/resource/throttle.py +0 -0
  28. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/semantic_rules.py +0 -0
  29. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/__init__.py +0 -0
  30. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/credential_tools.py +0 -0
  31. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/definition_tools.py +0 -0
  32. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/event_tools.py +0 -0
  33. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/job_tools.py +0 -0
  34. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/registry_tools.py +0 -0
  35. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/tools/sqlite_tools.py +0 -0
  36. {programgarden-1.24.0 → programgarden-1.24.2}/programgarden/validation_recommender.py +0 -0
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.24.0
3
+ Version: 1.24.2
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
+ License-Expression: AGPL-3.0-or-later
5
6
  Author: 프로그램동산
6
7
  Author-email: coding@programgarden.com
7
8
  Requires-Python: >=3.12
@@ -14,9 +15,9 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
14
15
  Requires-Dist: croniter (>=6.0.0,<7.0.0)
15
16
  Requires-Dist: litellm (>=1.40.0)
16
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
17
- Requires-Dist: programgarden-community (>=1.13.8,<2.0.0)
18
- Requires-Dist: programgarden-core (>=1.15.0,<2.0.0)
19
- Requires-Dist: programgarden-finance (>=1.6.10,<2.0.0)
18
+ Requires-Dist: programgarden-community (>=1.13.9,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.15.1,<2.0.0)
20
+ Requires-Dist: programgarden-finance (>=1.6.11,<2.0.0)
20
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
21
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
22
23
  Requires-Dist: pydantic (>=2.0.0,<3.0.0)
@@ -20,7 +20,10 @@ from programgarden.resolver import WorkflowResolver, ResolvedWorkflow, Validatio
20
20
  from programgarden_core import (
21
21
  BalanceUnavailableError,
22
22
  ConditionEvaluationError,
23
+ EmptyOrderReason,
24
+ OrderRejectInfo,
23
25
  ValidationLimits,
26
+ map_reject_code,
24
27
  )
25
28
  from programgarden.context import ExecutionContext, WorkflowEvent
26
29
  from programgarden.reconnect_handler import ReconnectHandler
@@ -1264,8 +1267,12 @@ class WatchlistNodeExecutor(NodeExecutorBase):
1264
1267
  context: ExecutionContext,
1265
1268
  **kwargs,
1266
1269
  ) -> Dict[str, Any]:
1270
+ # config 내 {{ }} 표현식 평가 (list-of-dict 중첩 포함 — MarketDataNode
1271
+ # 와 동일 사유. evaluate_fields 는 리스트 항목 dict 를 재귀하지 않음).
1272
+ config = evaluate_all_bindings(config, context, node_id)
1273
+
1267
1274
  symbols_raw = config.get("symbols", [])
1268
-
1275
+
1269
1276
  # symbols 처리: [{exchange, symbol}, ...] 형태로 정규화
1270
1277
  processed_symbols = []
1271
1278
  for entry in symbols_raw:
@@ -1421,6 +1428,10 @@ class ExclusionListNodeExecutor(NodeExecutorBase):
1421
1428
  context: ExecutionContext,
1422
1429
  **kwargs,
1423
1430
  ) -> Dict[str, Any]:
1431
+ # config 내 {{ }} 표현식 평가 (list-of-dict 중첩 포함 — MarketDataNode
1432
+ # 와 동일 사유. evaluate_fields 는 리스트 항목 dict 를 재귀하지 않음).
1433
+ config = evaluate_all_bindings(config, context, node_id)
1434
+
1424
1435
  default_reason = config.get("default_reason", "")
1425
1436
 
1426
1437
  # 1. 수동 입력 종목 (각 항목에 reason 포함 가능)
@@ -8976,6 +8987,13 @@ class MarketDataNodeExecutor(NodeExecutorBase):
8976
8987
  ) -> Dict[str, Any]:
8977
8988
  """현재가 조회"""
8978
8989
 
8990
+ # config 내 {{ }} 표현식 평가 (list-of-dict 중첩 포함).
8991
+ # 메인 루프의 _resolve_config_expressions(evaluate_fields)는 리스트
8992
+ # 항목이 dict 이면 재귀하지 않아 [{"symbol": "{{ ... }}"}] 형태의
8993
+ # 중첩 표현식이 literal 로 남는다. 전용 executor 는 evaluate_all_bindings
8994
+ # (dict/list 완전 재귀)를 직접 호출해야 한다. (형제 executor 패턴과 동일)
8995
+ config = evaluate_all_bindings(config, context, node_id)
8996
+
8979
8997
  # 입력 symbols 가져오기 (포트 또는 config에서 명시적 입력 필수)
8980
8998
  input_symbols = context.get_output(f"_input_{node_id}", "symbols")
8981
8999
  config_symbols = config.get("symbols")
@@ -9319,6 +9337,10 @@ class FundamentalNodeExecutor(NodeExecutorBase):
9319
9337
  ) -> Dict[str, Any]:
9320
9338
  """종목정보(펀더멘털) 조회"""
9321
9339
 
9340
+ # config 내 {{ }} 표현식 평가 (list-of-dict 중첩 포함 — MarketDataNode
9341
+ # 와 동일 사유. evaluate_fields 는 리스트 항목 dict 를 재귀하지 않음).
9342
+ config = evaluate_all_bindings(config, context, node_id)
9343
+
9322
9344
  # symbols 획득: input port → config.symbols → config.symbol (단일→배열 변환)
9323
9345
  input_symbols = context.get_output(f"_input_{node_id}", "symbols")
9324
9346
  config_symbols = config.get("symbols")
@@ -11369,7 +11391,13 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
11369
11391
 
11370
11392
  if not symbols:
11371
11393
  context.log("warning", "No symbols provided for position sizing", node_id)
11372
- return self._empty_result()
11394
+ # 상류가 빈 리스트에 error 신호를 동봉했으면 fetch_failed, 아니면 종목 미지정.
11395
+ err = None
11396
+ if isinstance(symbols_input, dict) and symbols_input.get("error"):
11397
+ err = str(symbols_input.get("error"))
11398
+ if err:
11399
+ return self._empty_result(EmptyOrderReason.FETCH_FAILED, err)
11400
+ return self._empty_result(EmptyOrderReason.NO_SYMBOL, "")
11373
11401
 
11374
11402
  # Refuse to silently zero-out when the upstream AccountNode flagged
11375
11403
  # a partial fetch. fixed_quantity ignores balance entirely, so it
@@ -11403,7 +11431,20 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
11403
11431
  # fixed_quantity 메서드는 balance 없이도 동작 (테스트용)
11404
11432
  if available_balance <= 0 and method != "fixed_quantity":
11405
11433
  context.log("warning", f"No available balance: {available_balance}", node_id)
11406
- return self._empty_result()
11434
+ # A zero balance caused by a partial fetch failure is a pipeline
11435
+ # error (fetch_failed), not a normal "no signal" no-op. The hard
11436
+ # _partial_failure raise above already covers non-dry-run; in
11437
+ # dry_run (exempt) still surface the failure reason in the result.
11438
+ if isinstance(balance_data, dict) and balance_data.get("_partial_failure") is True:
11439
+ detail = str(
11440
+ balance_data.get("_failure_reason")
11441
+ or "balance fetch partially failed"
11442
+ )
11443
+ return self._empty_result(EmptyOrderReason.FETCH_FAILED, detail)
11444
+ return self._empty_result(
11445
+ EmptyOrderReason.NO_SIGNAL,
11446
+ "No available balance for position sizing",
11447
+ )
11407
11448
 
11408
11449
  context.log(
11409
11450
  "info",
@@ -11435,12 +11476,18 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
11435
11476
  price_data, context, node_id
11436
11477
  )
11437
11478
  else:
11479
+ # Unknown sizing method is a configuration error, not a missing
11480
+ # signal — classify as NO_SYMBOL (config) so the consumer sees a
11481
+ # deterministic config-fix cue rather than a bare no_signal.
11438
11482
  context.log("error", f"Unknown sizing method: {method}", node_id)
11439
- return self._empty_result()
11440
-
11483
+ return self._empty_result(
11484
+ EmptyOrderReason.NO_SYMBOL,
11485
+ f"Unknown sizing method '{method}'",
11486
+ )
11487
+
11441
11488
  # 결과에 원본 symbols 추가
11442
11489
  result["symbols"] = symbols_input
11443
-
11490
+
11444
11491
  return result
11445
11492
 
11446
11493
  def _normalize_symbols(self, symbols_input: Any) -> List[Dict[str, Any]]:
@@ -11981,14 +12028,43 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
11981
12028
 
11982
12029
  return None
11983
12030
 
11984
- def _empty_result(self) -> Dict[str, Any]:
11985
- """빈 결과 반환"""
12031
+ # Human/AI-facing English explanations keyed by EmptyOrderReason.
12032
+ _EMPTY_SIZING_MESSAGES: Dict[str, str] = {
12033
+ EmptyOrderReason.NO_SIGNAL.value: (
12034
+ "No trading signal today (upstream produced an empty result normally)."
12035
+ ),
12036
+ EmptyOrderReason.FETCH_FAILED.value: (
12037
+ "Upstream data fetch failed; no order placed (pipeline error)."
12038
+ ),
12039
+ EmptyOrderReason.NO_SYMBOL.value: (
12040
+ "No symbol provided to the order node (configuration missing)."
12041
+ ),
12042
+ }
12043
+
12044
+ def _empty_result(
12045
+ self,
12046
+ reason: EmptyOrderReason = EmptyOrderReason.NO_SIGNAL,
12047
+ detail: str = "",
12048
+ ) -> Dict[str, Any]:
12049
+ """빈 포지션 사이징 결과 반환.
12050
+
12051
+ 주문 노드 ``_empty_result`` 와 동일하게 ``reason`` (no_signal / fetch_failed /
12052
+ no_symbol) 으로 "신호 없음" 과 "파이프라인 고장" 을 구분한다. 기존 키
12053
+ (orders/total_amount/symbols/method/config) 는 하위호환을 위해 그대로 두고
12054
+ ``reason`` / ``message`` / ``detail`` 만 추가한다.
12055
+ """
12056
+ message = self._EMPTY_SIZING_MESSAGES.get(
12057
+ reason.value, self._EMPTY_SIZING_MESSAGES[EmptyOrderReason.NO_SIGNAL.value]
12058
+ )
11986
12059
  return {
11987
12060
  "orders": [],
11988
12061
  "total_amount": 0.0,
11989
12062
  "symbols": [],
11990
12063
  "method": None,
11991
12064
  "config": {},
12065
+ "reason": reason.value,
12066
+ "message": message,
12067
+ "detail": detail,
11992
12068
  }
11993
12069
 
11994
12070
 
@@ -12448,6 +12524,13 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12448
12524
  paper_trading = broker_connection.get("paper_trading", False) if product != "korea_stock" else False
12449
12525
 
12450
12526
  # === 2. 바인딩 표현식 평가 ===
12527
+ # Capture the RAW (pre-evaluation) order binding expression BEFORE
12528
+ # evaluate_all_bindings rebinds config. A missing upstream port (e.g.
12529
+ # '{{ nodes.sizing.order }}' where sizing only emits 'orders') resolves
12530
+ # to None and the expression text is lost after evaluation — so the
12531
+ # upstream node reference needed for empty-order diagnosis must be read
12532
+ # from the raw config here.
12533
+ raw_order_expr = config.get("order")
12451
12534
  config = evaluate_all_bindings(config, context, node_id)
12452
12535
 
12453
12536
  # === 3. 주문 정보 추출 (order 단일 객체) ===
@@ -12460,7 +12543,10 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12460
12543
 
12461
12544
  if not normalized_order:
12462
12545
  context.log("warning", f"{node_type}: 주문할 종목이 없습니다", node_id)
12463
- return self._empty_result()
12546
+ reason, detail = self._diagnose_empty_reason(
12547
+ order, config, raw_order_expr, context
12548
+ )
12549
+ return self._empty_result(reason, detail)
12464
12550
 
12465
12551
  context.log(
12466
12552
  "info",
@@ -12621,6 +12707,155 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12621
12707
  "price": float(price) if price else 0.0,
12622
12708
  }
12623
12709
 
12710
+ def _diagnose_empty_reason(
12711
+ self,
12712
+ order: Any,
12713
+ config: Dict[str, Any],
12714
+ raw_order_expr: Any = None,
12715
+ context: Optional[ExecutionContext] = None,
12716
+ ) -> tuple:
12717
+ """빈 주문 결과의 reason 을 상류 신호로 판정.
12718
+
12719
+ no_signal(정상 빈 결과) vs fetch_failed(상류 파이프라인 고장) vs
12720
+ no_symbol(설정 누락) 을 결정적으로 구분한다. 메모리 정책(no silent
12721
+ failure)상 fetch_failed 미탐을 최소화하기 위해, 상류 노드의 FULL 구조화
12722
+ 출력(``error`` / ``reason='fetch_failed'`` / ``_partial_failure``) +
12723
+ balance ``_partial_failure`` + 미해석 바인딩 리터럴 같은 확실한 실패
12724
+ 신호를 우선 사용한다. 불확실하면 보수적으로 FETCH_FAILED 쪽으로 기운다.
12725
+
12726
+ ``raw_order_expr`` 는 evaluate_all_bindings 가 config 를 덮어쓰기 전에
12727
+ 캡처한 원본 ``order`` 바인딩 표현식이다. ``'{{ nodes.sizing.order }}'``
12728
+ 처럼 상류의 누락 포트를 가리켜 None 으로 해석된 경우, 그 표현식에서 상류
12729
+ 노드 id 를 뽑아 ``context.get_all_outputs`` 로 FULL 출력을 조회한다.
12730
+
12731
+ Args:
12732
+ order: evaluate 된 order 값(보통 None / dict / list).
12733
+ config: evaluate 된 노드 config.
12734
+ raw_order_expr: evaluate 이전 원본 order 표현식(없으면 None).
12735
+ context: 상류 출력 조회용 ExecutionContext(없으면 생략).
12736
+
12737
+ Returns:
12738
+ (EmptyOrderReason, detail) 튜플.
12739
+ """
12740
+ # 1) balance dict 의 부분 실패 → 상류 조회 실패.
12741
+ balance = config.get("balance")
12742
+ if isinstance(balance, dict) and balance.get("_partial_failure") is True:
12743
+ detail = str(balance.get("_failure_reason") or "balance fetch partially failed")
12744
+ return EmptyOrderReason.FETCH_FAILED, detail
12745
+
12746
+ # 2) order 바인딩이 미해석 리터럴('{{ ... }}') 로 그대로 넘어온 경우 —
12747
+ # 표현식이 평가되지 못했다(주로 상류 노드 미실행/오류). 보수적으로
12748
+ # FETCH_FAILED 로 본다(silent no-op 차단).
12749
+ if isinstance(order, str) and "{{" in order and "}}" in order:
12750
+ return (
12751
+ EmptyOrderReason.FETCH_FAILED,
12752
+ f"Order binding was not resolved (unresolved expression: {order})",
12753
+ )
12754
+
12755
+ # 3) 원본 order 표현식이 상류 노드를 가리키는데 None 으로 해석됐다면,
12756
+ # 그 상류 노드의 FULL 구조화 출력을 직접 조회해 실패 신호를 본다.
12757
+ # 핵심 케이스: '{{ nodes.sizing.order }}' 인데 sizing 이 'orders'(복수)
12758
+ # 만 내보내 'order' 포트가 없어 None 으로 swallow 된 경우 — 상류가
12759
+ # fetch_failed/_partial_failure 면 NO_SIGNAL 로 오분류하지 않는다.
12760
+ upstream = self._lookup_upstream_output(raw_order_expr, context)
12761
+ if upstream is not None:
12762
+ err = self._extract_upstream_error(upstream)
12763
+ if err is not None:
12764
+ return EmptyOrderReason.FETCH_FAILED, err
12765
+
12766
+ # 4) 상류가 낸 빈 리스트에 실패 신호 동봉 → 조회 실패(파이프라인 고장).
12767
+ # order/symbols 바인딩 결과가 dict 이면서 error/_partial_failure/
12768
+ # reason='fetch_failed' 를 들고 있는 경우를 본다.
12769
+ for candidate in (order, config.get("symbols"), config.get("order")):
12770
+ err = self._extract_upstream_error(candidate)
12771
+ if err is not None:
12772
+ return EmptyOrderReason.FETCH_FAILED, err
12773
+
12774
+ # 5) 주문 대상 자체가 비었으면(종목 미지정) → 설정 누락.
12775
+ # order 가 명시적으로 비어있고(빈 리스트/빈 dict/None) 상류 실패 신호도
12776
+ # 없는 경우는 "종목 미지정"으로 본다.
12777
+ if self._is_empty_order_payload(order):
12778
+ return EmptyOrderReason.NO_SYMBOL, ""
12779
+
12780
+ # 6) 그 외 → 정상 빈 결과(오늘 신호 없음).
12781
+ return EmptyOrderReason.NO_SIGNAL, ""
12782
+
12783
+ @staticmethod
12784
+ def _lookup_upstream_output(
12785
+ raw_order_expr: Any,
12786
+ context: Optional[ExecutionContext],
12787
+ ) -> Optional[Dict[str, Any]]:
12788
+ """원본 order 표현식이 '{{ nodes.<id>.<port> }}' 형태면 그 상류 노드의
12789
+ FULL 출력 dict 를 반환한다. 못 찾으면(미해석/auto-iterate item/노드 미실행)
12790
+ None.
12791
+
12792
+ Auto-iterate 의 ``{{ item.xxx }}`` 처럼 nodes.<id> 패턴이 없으면 None 으로
12793
+ graceful fallback 한다(기존 동작 유지).
12794
+ """
12795
+ if not isinstance(raw_order_expr, str) or context is None:
12796
+ return None
12797
+ import re
12798
+
12799
+ match = re.search(r"nodes\.(\w+)", raw_order_expr)
12800
+ if not match:
12801
+ return None
12802
+ node_id = match.group(1)
12803
+ try:
12804
+ outputs = context.get_all_outputs(node_id)
12805
+ except Exception:
12806
+ return None
12807
+ # 미실행/미지 노드 → {} → 빈 상류는 FETCH_FAILED 오판하지 않도록 None.
12808
+ if not outputs:
12809
+ return None
12810
+ return outputs
12811
+
12812
+ @staticmethod
12813
+ def _extract_upstream_error(candidate: Any) -> Optional[str]:
12814
+ """상류 출력(dict)에서 실패 신호를 추출. 없으면 None.
12815
+
12816
+ 인식하는 실패 신호(확장됨): ``error`` 키, ``reason == 'fetch_failed'``,
12817
+ ``_partial_failure is True``. 진단 텍스트는 detail/message/_failure_reason
12818
+ /error 순으로 골라 반환한다. 정상 결과 오탐을 막기 위해, ``symbols`` 가
12819
+ 채워져 있는데 단지 ``error`` 만 있는 경우(부분 경고)는 실패로 보지 않는다.
12820
+ """
12821
+ if not isinstance(candidate, dict):
12822
+ return None
12823
+
12824
+ # symbols 가 채워져 있으면 정상 결과로 본다(test_normal_nonempty_symbols).
12825
+ symbols = candidate.get("symbols")
12826
+ symbols_non_empty = bool(symbols) and symbols not in (None, [], ())
12827
+
12828
+ def _detail_text() -> str:
12829
+ for key in ("detail", "message", "_failure_reason", "error"):
12830
+ val = candidate.get(key)
12831
+ if val:
12832
+ return str(val)
12833
+ return "upstream fetch failed"
12834
+
12835
+ # (a) _partial_failure / reason=='fetch_failed' 는 명시적 실패 마커 —
12836
+ # symbols 유무와 무관하게 실패로 본다(silent no-op 차단 우선).
12837
+ if candidate.get("_partial_failure") is True:
12838
+ return _detail_text()
12839
+ if candidate.get("reason") == EmptyOrderReason.FETCH_FAILED.value:
12840
+ return _detail_text()
12841
+
12842
+ # (b) error 키 — symbols 가 비었을 때만 fetch_failed 로 본다(정상 오탐 방지).
12843
+ err = candidate.get("error")
12844
+ if err and not symbols_non_empty:
12845
+ # symbols 키가 아예 없거나 비어있는 경우만 실패로 본다.
12846
+ if symbols in (None, [], ()) or "symbols" not in candidate:
12847
+ return str(err)
12848
+ return None
12849
+
12850
+ @staticmethod
12851
+ def _is_empty_order_payload(order: Any) -> bool:
12852
+ """주문 페이로드가 '종목 미지정' 으로 볼 만큼 비었는지 판정."""
12853
+ if order is None:
12854
+ return True
12855
+ if isinstance(order, (list, tuple, dict, str)) and len(order) == 0:
12856
+ return True
12857
+ return False
12858
+
12624
12859
  async def _execute_overseas_stock(
12625
12860
  self,
12626
12861
  ls,
@@ -12698,8 +12933,19 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12698
12933
  context.log("debug", f"COSAT00301 response: rsp_cd={response.rsp_cd}, rsp_msg={response.rsp_msg}", node_id)
12699
12934
 
12700
12935
  if response.error_msg:
12701
- context.log("warning", f"Order failed: {symbol} - {response.error_msg}", node_id)
12702
- return self._order_result(False, symbol, exchange, side, qty, price, response.error_msg)
12936
+ reject = map_reject_code(
12937
+ "overseas_stock", response.rsp_cd or "", response.error_msg
12938
+ )
12939
+ context.log(
12940
+ "warning",
12941
+ f"Order failed: {symbol} - {response.error_msg} ({reject.cause})",
12942
+ node_id,
12943
+ )
12944
+ await self._notify_order_reject(context, node_id, symbol, reject)
12945
+ return self._order_result(
12946
+ False, symbol, exchange, side, qty, price,
12947
+ response.error_msg, reject_info=reject,
12948
+ )
12703
12949
 
12704
12950
  order_no = ""
12705
12951
  if response.block2:
@@ -12709,7 +12955,20 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12709
12955
  if not order_no:
12710
12956
  msg = response.rsp_msg or "주문번호 없음"
12711
12957
  context.log("warning", f"Order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
12712
- return self._order_result(False, symbol, exchange, side, qty, price, f"Empty OrderNo: {msg}")
12958
+ # rsp_cd 가 성공("00000")인데 OrderNo 케이스 rsp_cd 매핑이
12959
+ # 아니라 전용 진단을 동봉한다(장 마감 / 브로커 지연 등).
12960
+ reject = OrderRejectInfo(
12961
+ rsp_cd=response.rsp_cd or "",
12962
+ cause="Order accepted but no order number returned (likely market closed or broker delay)",
12963
+ tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
12964
+ raw_msg=msg,
12965
+ known=True,
12966
+ )
12967
+ await self._notify_order_reject(context, node_id, symbol, reject)
12968
+ return self._order_result(
12969
+ False, symbol, exchange, side, qty, price,
12970
+ f"Empty OrderNo: {msg}", reject_info=reject,
12971
+ )
12713
12972
 
12714
12973
  context.log("info", f"Order submitted: {symbol} {side} {qty}@{price} → order_id={order_no}", node_id)
12715
12974
 
@@ -12729,7 +12988,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12729
12988
 
12730
12989
  except Exception as e:
12731
12990
  context.log("warning", f"Order exception: {symbol} - {e}", node_id)
12732
- return self._order_result(False, symbol, exchange, side, qty, price, str(e))
12991
+ # 예외는 rsp_cd 없으므로 known=False 폴백으로 동봉(일관성).
12992
+ reject = map_reject_code("overseas_stock", "", str(e))
12993
+ return self._order_result(
12994
+ False, symbol, exchange, side, qty, price, str(e), reject_info=reject,
12995
+ )
12733
12996
 
12734
12997
  return {
12735
12998
  "order_result": {
@@ -12962,19 +13225,49 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12962
13225
  context.log("debug", f"CIDBT00100 response: rsp_cd={response.rsp_cd}, rsp_msg={response.rsp_msg}", node_id)
12963
13226
 
12964
13227
  if response.error_msg:
12965
- context.log("warning", f"Order failed: {symbol} - {response.error_msg}", node_id)
12966
- return self._order_result(False, symbol, exchange, side, qty, price, response.error_msg)
13228
+ reject = map_reject_code(
13229
+ "overseas_futures", response.rsp_cd or "", response.error_msg
13230
+ )
13231
+ context.log(
13232
+ "warning",
13233
+ f"Order failed: {symbol} - {response.error_msg} ({reject.cause})",
13234
+ node_id,
13235
+ )
13236
+ await self._notify_order_reject(
13237
+ context, node_id, symbol, reject,
13238
+ node_type="OverseasFuturesNewOrderNode",
13239
+ )
13240
+ return self._order_result(
13241
+ False, symbol, exchange, side, qty, price,
13242
+ response.error_msg, reject_info=reject,
13243
+ )
12967
13244
 
12968
13245
  order_no = ""
12969
13246
  if response.block2:
12970
- # 해외선물 주문번호 필드: OvrsFutsOrdNo
13247
+ # 해외선물 주문번호 필드: OvrsFutsOrdNo (str, default "")
12971
13248
  order_no = str(response.block2.OvrsFutsOrdNo) if hasattr(response.block2, "OvrsFutsOrdNo") and response.block2.OvrsFutsOrdNo else ""
12972
13249
 
12973
13250
  # 주문번호가 없으면 경고 + 기록 거부
12974
13251
  if not order_no:
12975
13252
  msg = response.rsp_msg or "주문번호 없음"
12976
13253
  context.log("warning", f"Futures order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
12977
- return self._order_result(False, symbol, exchange, side, qty, price, f"Empty OrderNo: {msg}")
13254
+ # rsp_cd 성공인데 OrderNo 케이스 전용 lifecycle 진단 동봉
13255
+ # (장 마감 / 브로커 지연 등). stock 경로와 동일 구조.
13256
+ reject = OrderRejectInfo(
13257
+ rsp_cd=response.rsp_cd or "",
13258
+ cause="Order accepted but no order number returned (likely market closed or broker delay)",
13259
+ tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
13260
+ raw_msg=msg,
13261
+ known=True,
13262
+ )
13263
+ await self._notify_order_reject(
13264
+ context, node_id, symbol, reject,
13265
+ node_type="OverseasFuturesNewOrderNode",
13266
+ )
13267
+ return self._order_result(
13268
+ False, symbol, exchange, side, qty, price,
13269
+ f"Empty OrderNo: {msg}", reject_info=reject,
13270
+ )
12978
13271
 
12979
13272
  context.log("info", f"Futures order submitted: {symbol} {side} {qty}@{price} → order_id={order_no}", node_id)
12980
13273
 
@@ -12994,7 +13287,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
12994
13287
 
12995
13288
  except Exception as e:
12996
13289
  context.log("warning", f"Futures order exception: {symbol} - {e}", node_id)
12997
- return self._order_result(False, symbol, exchange, side, qty, price, str(e))
13290
+ # 예외는 rsp_cd 없으므로 known=False 폴백으로 동봉(stock 경로와 일관).
13291
+ reject = map_reject_code("overseas_futures", "", str(e))
13292
+ return self._order_result(
13293
+ False, symbol, exchange, side, qty, price, str(e), reject_info=reject,
13294
+ )
12998
13295
 
12999
13296
  def _check_exclusion_list(
13000
13297
  self,
@@ -13067,15 +13364,47 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13067
13364
  response = await order_api.req_async()
13068
13365
 
13069
13366
  if response.error_msg:
13070
- context.log("warning", f"Korea stock order failed: {response.error_msg}", node_id)
13367
+ reject = map_reject_code(
13368
+ "korea_stock", response.rsp_cd or "", response.error_msg
13369
+ )
13370
+ context.log(
13371
+ "warning",
13372
+ f"Korea stock order failed: {response.error_msg} ({reject.cause})",
13373
+ node_id,
13374
+ )
13375
+ await self._notify_order_reject(
13376
+ context, node_id, symbol, reject,
13377
+ node_type="KoreaStockNewOrderNode",
13378
+ )
13071
13379
  return self._order_result(
13072
13380
  False, symbol, "KRX", side, qty, price,
13073
- response.error_msg
13381
+ response.error_msg, reject_info=reject,
13074
13382
  )
13075
13383
 
13076
- order_no = ""
13077
- if response.block2:
13078
- order_no = str(response.block2.OrdNo) if response.block2.OrdNo else ""
13384
+ # CSPAT00601 OrdNo 는 int(default 0). str(0)=='0' 은 truthy 라서
13385
+ # OrdNo 가 0/garbage 면 성공으로 silently 기록되는 위험이 있다.
13386
+ # stock 경로와 동일하게 빈/0 OrderNo 를 명시 거부로 본다.
13387
+ ord_no_raw = response.block2.OrdNo if response.block2 else None
13388
+ order_no = str(ord_no_raw) if ord_no_raw else ""
13389
+
13390
+ if not order_no:
13391
+ msg = response.rsp_msg or "주문번호 없음"
13392
+ context.log("warning", f"Korea stock order submitted but no OrderNo returned: {symbol} - {msg}", node_id)
13393
+ reject = OrderRejectInfo(
13394
+ rsp_cd=response.rsp_cd or "",
13395
+ cause="Order accepted but no order number returned (likely market closed or broker delay)",
13396
+ tip="Verify market hours and re-query open orders to confirm whether the order was actually placed.",
13397
+ raw_msg=msg,
13398
+ known=True,
13399
+ )
13400
+ await self._notify_order_reject(
13401
+ context, node_id, symbol, reject,
13402
+ node_type="KoreaStockNewOrderNode",
13403
+ )
13404
+ return self._order_result(
13405
+ False, symbol, "KRX", side, qty, price,
13406
+ f"Empty OrderNo: {msg}", reject_info=reject,
13407
+ )
13079
13408
 
13080
13409
  context.log(
13081
13410
  "info",
@@ -13092,9 +13421,47 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13092
13421
 
13093
13422
  except Exception as e:
13094
13423
  context.log("warning", f"Korea stock order exception: {e}", node_id)
13424
+ reject = map_reject_code("korea_stock", "", str(e))
13095
13425
  return self._order_result(
13096
- False, symbol, "KRX", side, qty, price, str(e)
13426
+ False, symbol, "KRX", side, qty, price, str(e), reject_info=reject,
13427
+ )
13428
+
13429
+ async def _notify_order_reject(
13430
+ self,
13431
+ context: ExecutionContext,
13432
+ node_id: str,
13433
+ symbol: str,
13434
+ reject: OrderRejectInfo,
13435
+ node_type: str = "OverseasStockNewOrderNode",
13436
+ ) -> None:
13437
+ """주문 거부 시 투자자 알림 1건 발행(on_notification).
13438
+
13439
+ on_log warning 과 별개로, AI/UI/텔레그램 소비자가 거부 사유(cause)와 대응
13440
+ 팁(tip)을 구조화 payload 로 받도록 한다. ``node_type`` 은 시장별 주문 노드
13441
+ 이름(해외주식/해외선물/국내주식)을 정확히 라벨링하기 위해 호출자가 전달한다.
13442
+ """
13443
+ try:
13444
+ await context.send_notification(
13445
+ # ORDER_REJECTED is the semantically correct category for a
13446
+ # broker reject (RISK_ALERT means drawdown/risk-halt).
13447
+ category=NotificationCategory.ORDER_REJECTED,
13448
+ severity=NotificationSeverity.WARNING,
13449
+ title=f"Order rejected: {symbol}",
13450
+ message=f"{symbol} order rejected — {reject.cause}",
13451
+ node_id=node_id,
13452
+ node_type=node_type,
13453
+ data={
13454
+ "symbol": symbol,
13455
+ "rsp_cd": reject.rsp_cd,
13456
+ "cause": reject.cause,
13457
+ "tip": reject.tip,
13458
+ "raw_msg": reject.raw_msg,
13459
+ "known": reject.known,
13460
+ },
13097
13461
  )
13462
+ except Exception:
13463
+ # 알림 전파 실패가 주문 결과 반환을 막아서는 안 된다.
13464
+ pass
13098
13465
 
13099
13466
  def _order_result(
13100
13467
  self,
@@ -13106,8 +13473,15 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13106
13473
  price: float,
13107
13474
  error: Optional[str] = None,
13108
13475
  order_id: str = "",
13476
+ reject_info: Optional[OrderRejectInfo] = None,
13109
13477
  ) -> Dict[str, Any]:
13110
- """단일 주문 결과 반환"""
13478
+ """단일 주문 결과 반환.
13479
+
13480
+ ``error`` (기존 LS 원문 단일 문자열) 은 하위호환을 위해 그대로 유지하고,
13481
+ 구조화된 거부 진단(``rsp_cd`` / ``cause`` / ``tip`` / ``raw_msg`` / ``known``)
13482
+ 은 ``order_result.diagnostics`` 로 추가 동봉한다. AI 챗봇 소비자는
13483
+ ``diagnostics`` 로 자가수정/안내를 결정적으로 분기할 수 있다.
13484
+ """
13111
13485
  return {
13112
13486
  "order_result": {
13113
13487
  "success": success,
@@ -13118,16 +13492,46 @@ class NewOrderNodeExecutor(NodeExecutorBase):
13118
13492
  "price": price,
13119
13493
  "status": "submitted" if success else "failed",
13120
13494
  "error": error,
13495
+ "diagnostics": reject_info.model_dump() if reject_info else None,
13121
13496
  },
13122
13497
  "order_id": order_id,
13123
13498
  }
13124
13499
 
13125
- def _empty_result(self) -> Dict[str, Any]:
13126
- """빈 결과 반환"""
13500
+ # Human/AI-facing English explanations keyed by EmptyOrderReason.
13501
+ _EMPTY_ORDER_MESSAGES: Dict[str, str] = {
13502
+ EmptyOrderReason.NO_SIGNAL.value: (
13503
+ "No trading signal today (upstream produced an empty result normally)."
13504
+ ),
13505
+ EmptyOrderReason.FETCH_FAILED.value: (
13506
+ "Upstream data fetch failed; no order placed (pipeline error)."
13507
+ ),
13508
+ EmptyOrderReason.NO_SYMBOL.value: (
13509
+ "No symbol provided to the order node (configuration missing)."
13510
+ ),
13511
+ }
13512
+
13513
+ def _empty_result(
13514
+ self,
13515
+ reason: EmptyOrderReason = EmptyOrderReason.NO_SIGNAL,
13516
+ detail: str = "",
13517
+ ) -> Dict[str, Any]:
13518
+ """빈 주문 결과 반환.
13519
+
13520
+ ``reason`` (no_signal / fetch_failed / no_symbol) 로 "오늘 신호 없음(정상)"
13521
+ 과 "상류 파이프라인 고장(이상)" 을 결정적으로 구분한다. 기존 ``error`` 키는
13522
+ 하위호환을 위해 보존하되, reason 기반 영어 설명(``message``)을 추가한다.
13523
+ ``detail`` 은 상류 error 원문 등 진단 부가정보(없으면 빈 문자열).
13524
+ """
13525
+ message = self._EMPTY_ORDER_MESSAGES.get(
13526
+ reason.value, self._EMPTY_ORDER_MESSAGES[EmptyOrderReason.NO_SIGNAL.value]
13527
+ )
13127
13528
  return {
13128
13529
  "order_result": {
13129
13530
  "success": False,
13130
13531
  "error": "No order to submit",
13532
+ "reason": reason.value,
13533
+ "message": message,
13534
+ "detail": detail,
13131
13535
  },
13132
13536
  "order_id": "",
13133
13537
  }
@@ -17876,8 +18280,22 @@ class WorkflowJob:
17876
18280
  # A-4: schedule_tick 은 정당한 신규 ordering 세대 → cycle +1.
17877
18281
  # (entry 세대 0 과 분리되어 동일 주문이 매 tick 차단되지 않음)
17878
18282
  self._order_cycle += 1
17879
- await self._execute_main_flow()
17880
- await self.context.notify_job_state("cycle_completed", self.stats)
18283
+ # 사이클 격리: 단일 사이클의 노드 예외(잔고 부분실패의
18284
+ # ConditionEvaluationError, ScreenerNode silent-failure 가드의
18285
+ # RuntimeError 등)가 스케줄 잡 전체를 종료시키지 않도록 한다.
18286
+ # 실패 사이클은 silent 하지 않게 'cycle_failed' 로 통지하고
18287
+ # 다음 tick 에서 재시도한다 (24시간 무인 운영 보호).
18288
+ try:
18289
+ await self._execute_main_flow()
18290
+ except Exception as cycle_err:
18291
+ self.stats["errors_count"] = self.stats.get("errors_count", 0) + 1
18292
+ self.context.log(
18293
+ "error",
18294
+ f"Schedule cycle failed (job continues to next tick): {cycle_err}",
18295
+ )
18296
+ await self.context.notify_job_state("cycle_failed", self.stats)
18297
+ else:
18298
+ await self.context.notify_job_state("cycle_completed", self.stats)
17881
18299
 
17882
18300
  logger.info("Event loop ended")
17883
18301
 
@@ -5,7 +5,8 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.24.0"
8
+ version = "1.24.2"
9
+ license = "AGPL-3.0-or-later"
9
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
10
11
  readme = "README.md"
11
12
 
@@ -28,9 +29,9 @@ lxml = "^6.0.2"
28
29
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
29
30
  aiosqlite = "^0.20.0"
30
31
  litellm = ">=1.40.0"
31
- programgarden-core = "^1.15.0"
32
- programgarden-finance = "^1.6.10"
33
- programgarden-community = "^1.13.8"
32
+ programgarden-core = "^1.15.1"
33
+ programgarden-finance = "^1.6.11"
34
+ programgarden-community = "^1.13.9"
34
35
 
35
36
  [tool.poetry.group.dev.dependencies]
36
37
  pytest = "^8.0.0"
File without changes