programgarden 1.26.0__tar.gz → 1.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {programgarden-1.26.0 → programgarden-1.27.0}/PKG-INFO +1 -1
  2. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/__init__.py +1 -1
  3. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/executor.py +173 -106
  4. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resolver.py +194 -0
  5. {programgarden-1.26.0 → programgarden-1.27.0}/pyproject.toml +1 -1
  6. {programgarden-1.26.0 → programgarden-1.27.0}/README.md +0 -0
  7. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/client.py +0 -0
  9. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/context.py +0 -0
  11. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.26.0 → programgarden-1.27.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.26.0
3
+ Version: 1.27.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -91,7 +91,7 @@ except ImportError:
91
91
  # Community package not installed
92
92
  pass
93
93
 
94
- __version__ = "1.18.0"
94
+ __version__ = "1.27.0"
95
95
  __all__ = [
96
96
  # Core
97
97
  "ProgramGarden",
@@ -3129,11 +3129,15 @@ class BrokerNodeExecutor(NodeExecutorBase):
3129
3129
  "paper_trading=False로 설정하거나, 해외선물(overseas_futures)을 사용하세요.",
3130
3130
  node_id,
3131
3131
  )
3132
- return {
3133
- "connection": None,
3134
- "error": "overseas_stock does not support paper_trading. Set paper_trading=False to use real mode.",
3135
- }
3136
-
3132
+ # 하드 실패(지원하지 않는 설정 조합) → raise. 에러-dict 로 굴러가면 하류가
3133
+ # connected/connection 을 침묵의 False/None 으로 먹고 워크플로우가 완주한다.
3134
+ from programgarden_core.exceptions import ValidationError
3135
+ raise ValidationError(
3136
+ "overseas_stock does not support paper_trading (LS-Sec limitation). Set "
3137
+ "paper_trading=False, or use overseas_futures for paper trading.",
3138
+ node_id=node_id,
3139
+ )
3140
+
3137
3141
  # ========================================
3138
3142
  # Credential 자동 주입 (GenericNodeExecutor와 동일 패턴)
3139
3143
  # credential_id가 있으면 appkey, appsecret이 config에 주입됨
@@ -7789,6 +7793,26 @@ class DisplayNodeExecutor(NodeExecutorBase):
7789
7793
  return str(value)[:15] + "..." if len(str(value)) > 15 else str(value)
7790
7794
  return str(value)[:20]
7791
7795
 
7796
+ def _normalize_columns(self, columns: Any) -> Optional[List[tuple]]:
7797
+ """`columns` 설정을 [(key, label), ...] 로 정규화.
7798
+
7799
+ 스키마 정본은 `List[str]`(예: ["symbol","close"])이지만, LLM 이 리치 객체형
7800
+ [{"key":"date","label":"날짜"}] 을 뱉는 경우가 있다. 이때 옛 렌더러는
7801
+ `f"{c:<12}"`(c=dict)에서 `unsupported format string passed to dict.__format__`
7802
+ 로 크래시했다. 양형을 모두 받아 (조회 key, 표시 label)로 환원한다."""
7803
+ if not columns:
7804
+ return None
7805
+ norm: List[tuple] = []
7806
+ for c in columns:
7807
+ if isinstance(c, dict):
7808
+ key = c.get("key") or c.get("name") or c.get("field") or c.get("label")
7809
+ label = c.get("label") or c.get("title") or key
7810
+ if key:
7811
+ norm.append((str(key), str(label)))
7812
+ elif c is not None:
7813
+ norm.append((str(c), str(c)))
7814
+ return norm or None
7815
+
7792
7816
  def _get_data(self, config: Dict[str, Any], context: ExecutionContext, node_id: str) -> Any:
7793
7817
  """data 필드 또는 엣지에서 데이터 가져오기"""
7794
7818
  # 1. config의 data 필드가 바인딩 표현식인 경우 이미 평가됨
@@ -7899,12 +7923,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
7899
7923
  rows = sorted(rows, key=lambda x: x.get(sort_by, 0), reverse=(sort_order == "desc"))
7900
7924
  rows = rows[:limit]
7901
7925
 
7902
- cols = columns or list(rows[0].keys())[:8]
7903
- header = " | ".join(f"{c:<12}" for c in cols)
7926
+ cols = self._normalize_columns(columns) or [
7927
+ (k, k) for k in list(rows[0].keys())[:8]
7928
+ ]
7929
+ header = " | ".join(f"{label:<12}" for _, label in cols)
7904
7930
  print(header)
7905
7931
  print("-" * 80)
7906
7932
  for row in rows:
7907
- values = " | ".join(f"{self._format_value(row.get(c)):<12}" for c in cols)
7933
+ values = " | ".join(
7934
+ f"{self._format_value(row.get(key)):<12}" for key, _ in cols
7935
+ )
7908
7936
  print(values)
7909
7937
 
7910
7938
  elif isinstance(data, dict):
@@ -7917,12 +7945,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
7917
7945
  items = items[:limit]
7918
7946
 
7919
7947
  if items:
7920
- cols = columns or list(items[0][1].keys())[:6]
7921
- header = f"{'Key':<12} | " + " | ".join(f"{c:<12}" for c in cols)
7948
+ cols = self._normalize_columns(columns) or [
7949
+ (k, k) for k in list(items[0][1].keys())[:6]
7950
+ ]
7951
+ header = f"{'Key':<12} | " + " | ".join(f"{label:<12}" for _, label in cols)
7922
7952
  print(header)
7923
7953
  print("-" * 80)
7924
7954
  for key, val in items:
7925
- values = f"{key:<12} | " + " | ".join(f"{self._format_value(val.get(c)):<12}" for c in cols)
7955
+ values = f"{key:<12} | " + " | ".join(
7956
+ f"{self._format_value(val.get(col_key)):<12}" for col_key, _ in cols
7957
+ )
7926
7958
  print(values)
7927
7959
  else:
7928
7960
  # flat dict
@@ -8416,6 +8448,7 @@ class ConditionNodeExecutor(NodeExecutorBase):
8416
8448
  ) -> Dict[str, Any]:
8417
8449
  from programgarden.plugin import PluginSandbox, PluginTimeoutError
8418
8450
  from programgarden_core.models import get_plugin_hints
8451
+ from programgarden_core.exceptions import ValidationError, ExecutionError
8419
8452
 
8420
8453
  # 플러그인 ID 추출
8421
8454
  plugin_id = config.get("plugin", "Unknown")
@@ -8430,15 +8463,14 @@ class ConditionNodeExecutor(NodeExecutorBase):
8430
8463
  )
8431
8464
 
8432
8465
  if not resource_check["can_proceed"]:
8433
- context.log("warning", f"Resource limit reached: {resource_check['reason']}", node_id)
8434
- # 리소스 부족 결과 반환 (안전 모드)
8435
- return {
8436
- "result": False,
8437
- "passed_symbols": [],
8438
- "failed_symbols": [],
8439
- "values": {},
8440
- "error": "resource_limit",
8441
- }
8466
+ # 하드 실패(리소스 고갈로 실행 불가) raise. 빈 결과를 성공으로 위장하면
8467
+ # 하류 IfNode is_condition_met=False 조용히 먹어 잘못된 분기를 탄다.
8468
+ context.log("error", f"Resource limit reached: {resource_check['reason']}", node_id)
8469
+ raise ExecutionError(
8470
+ f"ConditionNode cannot run — resource limit reached: "
8471
+ f"{resource_check['reason']}",
8472
+ node_id=node_id,
8473
+ )
8442
8474
 
8443
8475
  # 권장 배치 크기 저장 (백테스트 모드에서 사용)
8444
8476
  recommended_batch_size = resource_check.get("recommended_batch_size", 10)
@@ -8482,18 +8514,22 @@ class ConditionNodeExecutor(NodeExecutorBase):
8482
8514
  context.log("debug", f"positions 평가 후: {type(positions).__name__}, {len(positions) if isinstance(positions, (list, dict)) else 0} items", node_id)
8483
8515
 
8484
8516
  if not positions or not isinstance(positions, list):
8485
- context.log("error",
8486
- f"ConditionNode '{node_id}': positions 설정되지 않았습니다. "
8487
- f"config에 positions: {{{{ nodes.realAccount.positions }}}} 형태로 추가하세요.",
8517
+ # 정상 0건: 보유 포지션이 없으면( 리스트) 검사할 대상이 없어 신호 0건.
8518
+ # `positions` 바인딩이 아예 없는 config 오류는 static validate 가
8519
+ # (resolver._validate: position-plugin positions) 이미 잡으므로,
8520
+ # 런타임 빈/미해결은 하드 실패로 죽이지 않고 정상 빈 값으로 흘린다.
8521
+ context.log("info",
8522
+ f"ConditionNode '{node_id}': positions 비어있음 → 통과 종목 없음(정상 0건).",
8488
8523
  node_id
8489
8524
  )
8490
8525
  return {
8526
+ "symbols": [],
8491
8527
  "result": False,
8528
+ "is_condition_met": False,
8492
8529
  "passed_symbols": [],
8493
8530
  "failed_symbols": [],
8531
+ "symbol_results": [],
8494
8532
  "values": [],
8495
- "error": "missing_positions",
8496
- "error_message": "positions가 설정되지 않았습니다. 예: {{ nodes.realAccount.positions }}",
8497
8533
  }
8498
8534
 
8499
8535
  # fields 표현식 평가
@@ -8542,28 +8578,28 @@ class ConditionNodeExecutor(NodeExecutorBase):
8542
8578
  return {
8543
8579
  "symbols": [p.get("symbol") for p in positions if isinstance(p, dict)],
8544
8580
  "result": True if (getattr(context, "is_deep_validate", False) and _passed) else result.get("result", False),
8581
+ "is_condition_met": True if (getattr(context, "is_deep_validate", False) and _passed) else result.get("result", False),
8545
8582
  "passed_symbols": _passed,
8546
8583
  "failed_symbols": [] if (getattr(context, "is_deep_validate", False) and _passed) else result.get("failed_symbols", []),
8547
8584
  "symbol_results": result.get("symbol_results", []),
8548
8585
  "values": result.get("values", []),
8549
8586
  }
8550
8587
  except Exception as e:
8588
+ # 하드 실패(플러그인 실행 예외) → raise.
8551
8589
  context.log("error", f"Plugin error: {e}", node_id)
8552
8590
  import traceback
8553
8591
  context.log("debug", f"Plugin traceback: {traceback.format_exc()}", node_id)
8554
- return {
8555
- "result": False,
8556
- "passed_symbols": [],
8557
- "failed_symbols": [],
8558
- "values": [],
8559
- "error": str(e),
8560
- }
8592
+ raise ExecutionError(
8593
+ f"ConditionNode plugin execution failed: {e}",
8594
+ node_id=node_id,
8595
+ ) from e
8561
8596
  else:
8562
8597
  # 플러그인 없으면 모두 통과
8563
8598
  passed_symbols = [{"symbol": s, "exchange": "UNKNOWN"} for s in positions.keys()]
8564
8599
  return {
8565
8600
  "symbols": list(positions.keys()),
8566
8601
  "result": True,
8602
+ "is_condition_met": True,
8567
8603
  "passed_symbols": passed_symbols,
8568
8604
  "failed_symbols": [],
8569
8605
  "symbol_results": [],
@@ -8580,14 +8616,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
8580
8616
  f"items {{ from, extract }} 형태로 추가하세요.",
8581
8617
  node_id
8582
8618
  )
8583
- return {
8584
- "result": False,
8585
- "passed_symbols": [],
8586
- "failed_symbols": [],
8587
- "values": [],
8588
- "error": "missing_items",
8589
- "error_message": "items가 설정되지 않았습니다. items { from, extract } 형태로 추가하세요.",
8590
- }
8619
+ # 하드 실패(필수입력 부재) → raise.
8620
+ raise ValidationError(
8621
+ "ConditionNode uses an indicator plugin but has no `items` binding. Add "
8622
+ "items { from, extract } — e.g. items: { from: '{{ nodes.hist.values }}', "
8623
+ "extract: { symbol: '{{ item.symbol }}', close: '{{ row.close }}' } }.",
8624
+ node_id=node_id,
8625
+ )
8591
8626
 
8592
8627
  # === 플러그인 required_fields 검증 ===
8593
8628
  if plugin_schema and hasattr(plugin_schema, 'required_fields'):
@@ -8600,30 +8635,33 @@ class ConditionNodeExecutor(NodeExecutorBase):
8600
8635
  f"플러그인 {plugin_id}는 {required_fields} 필드가 필요합니다.",
8601
8636
  node_id
8602
8637
  )
8603
- return {
8604
- "result": False,
8605
- "passed_symbols": [],
8606
- "failed_symbols": [],
8607
- "values": [],
8608
- "error": "missing_required_fields",
8609
- "error_message": f"extract에 필수 필드 누락: {missing}",
8610
- }
8638
+ # 하드 실패(필수입력 부재 — 플러그인 required_fields 누락) → raise.
8639
+ raise ValidationError(
8640
+ f"ConditionNode plugin '{plugin_id}' requires extract fields "
8641
+ f"{required_fields}, but these are missing: {missing}. Add them to "
8642
+ f"items.extract.",
8643
+ node_id=node_id,
8644
+ )
8611
8645
 
8612
8646
  # items 처리 (from 배열을 순회하며 extract 적용)
8613
8647
  data = self._process_items_with_extract(items_config, context, node_id)
8614
8648
 
8615
8649
  if not data:
8616
- context.log("error",
8617
- f"ConditionNode '{node_id}': items 처리 결과가 비어있습니다.",
8650
+ # 정상 0건(런타임에 from 배열이 비어 평가할 항목 없음 — 예: 장 마감/무거래).
8651
+ # 하드 실패가 아니므로 선언 스키마 그대로 빈 값(no signal). error 키 없음.
8652
+ # 구조적 미배선은 B′ SplitNode/도달성 가드·deep_validate 가 별도로 잡는다.
8653
+ context.log("info",
8654
+ f"ConditionNode '{node_id}': items 처리 결과가 비어있어 통과 종목 없음(정상 0건).",
8618
8655
  node_id
8619
8656
  )
8620
8657
  return {
8658
+ "symbols": [],
8621
8659
  "result": False,
8660
+ "is_condition_met": False,
8622
8661
  "passed_symbols": [],
8623
8662
  "failed_symbols": [],
8663
+ "symbol_results": [],
8624
8664
  "values": [],
8625
- "error": "empty_items_result",
8626
- "error_message": "items 처리 결과가 비어있습니다. from 배열을 확인하세요.",
8627
8665
  }
8628
8666
 
8629
8667
  # symbols 자동 추출 (data에서)
@@ -8674,14 +8712,13 @@ class ConditionNodeExecutor(NodeExecutorBase):
8674
8712
  field_mapping=field_mapping,
8675
8713
  )
8676
8714
  except PluginTimeoutError as e:
8715
+ # 하드 실패(플러그인 타임아웃) → raise.
8716
+ from programgarden_core.exceptions import ExecutionError
8677
8717
  context.log("error", f"Plugin timeout: {e}", node_id)
8678
- return {
8679
- "result": False,
8680
- "passed_symbols": [],
8681
- "failed_symbols": symbols if 'symbols' in locals() else [],
8682
- "values": {},
8683
- "error": "plugin_timeout",
8684
- }
8718
+ raise ExecutionError(
8719
+ f"ConditionNode plugin timed out: {e}",
8720
+ node_id=node_id,
8721
+ ) from e
8685
8722
  finally:
8686
8723
  # 리소스 반환
8687
8724
  await context.release_resources_after_task(
@@ -10019,8 +10056,20 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10019
10056
  symbol_exchange_map[sym] = mc
10020
10057
 
10021
10058
  if not symbols:
10059
+ # NOTE: 정상 return(아래 9690)과 **동일 스키마**로 반환해야 한다. 옛
10060
+ # `{"ohlcv_data": {}, "symbols": []}` 는 정상 형태(value/values/period/
10061
+ # interval)와 완전히 달라, 하류의 정상 바인딩(`{{ nodes.x.value.time_series }}`
10062
+ # / ConditionNode 의 `item.time_series`)이 조용히 None 이 되고 방어적
10063
+ # `data or []` 가 그걸 삼켜 에러 없이 빈 결과(count:0)를 낸다.
10064
+ # (deep_fixtures.historical_data_fixture 도 이 스키마를 못박는다.)
10022
10065
  context.log("warning", "No symbols provided", node_id)
10023
- return {"ohlcv_data": {}, "symbols": []}
10066
+ return {
10067
+ "value": None,
10068
+ "values": [],
10069
+ "symbols": [],
10070
+ "period": "",
10071
+ "interval": config.get("interval", "1d"),
10072
+ }
10024
10073
 
10025
10074
  # 기간 설정 ({{ today_yyyymmdd() }}, {{ days_ago_yyyymmdd(100) }} 바인딩 사용)
10026
10075
  start_date = config.get("start_date", "")
@@ -10685,14 +10734,15 @@ class BacktestEngineNodeExecutor(NodeExecutorBase):
10685
10734
  )
10686
10735
 
10687
10736
  if not resource_check["can_proceed"]:
10737
+ # 하드 실패(리소스 고갈로 실행 불가) → raise(빈 결과를 성공으로 위장하지 않음).
10738
+ from programgarden_core.exceptions import ExecutionError
10688
10739
  context.log("error", f"Cannot run backtest: {resource_check['reason']}", node_id)
10689
- return {
10690
- "equity_curve": [],
10691
- "trades": [],
10692
- "metrics": {},
10693
- "summary": {"error": resource_check["reason"]},
10694
- }
10695
-
10740
+ raise ExecutionError(
10741
+ f"BacktestEngineNode cannot run — resource limit reached: "
10742
+ f"{resource_check['reason']}",
10743
+ node_id=node_id,
10744
+ )
10745
+
10696
10746
  try:
10697
10747
  # === items { from, extract } 방식으로 입력 데이터 가져오기 ===
10698
10748
  items_config = config.get("items")
@@ -10709,6 +10759,8 @@ class BacktestEngineNodeExecutor(NodeExecutorBase):
10709
10759
  return {
10710
10760
  "equity_curve": [],
10711
10761
  "trades": [],
10762
+ "signals": [],
10763
+ "values": [],
10712
10764
  "metrics": {},
10713
10765
  "summary": {"error": "items 처리 결과가 비어있습니다."},
10714
10766
  }
@@ -11695,6 +11747,7 @@ class PortfolioNodeExecutor(NodeExecutorBase):
11695
11747
  "rebalance_signal": False,
11696
11748
  "rebalance_orders": [],
11697
11749
  "allocated_capital": {},
11750
+ "drawdown_percent": 0,
11698
11751
  }
11699
11752
 
11700
11753
 
@@ -14165,16 +14218,14 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14165
14218
  }
14166
14219
 
14167
14220
  except Exception as e:
14168
- context.log("warning", f"Modify order exception: {e}", node_id)
14169
- return {
14170
- "modify_result": {
14171
- "success": False,
14172
- "error": str(e),
14173
- "original_order_id": original_order_id,
14174
- "product": "overseas_stock",
14175
- },
14176
- "modified_order": None,
14177
- }
14221
+ # 하드 실패 → raise(에러-dict 삼키면 하류가 침묵의 None → silent garbage).
14222
+ from programgarden_core.exceptions import ExecutionError
14223
+ context.log("error", f"Modify order exception (overseas_stock): {e}", node_id)
14224
+ raise ExecutionError(
14225
+ f"ModifyOrderNode failed to modify overseas_stock order "
14226
+ f"'{original_order_id}': {e}",
14227
+ node_id=node_id,
14228
+ ) from e
14178
14229
 
14179
14230
  async def _modify_overseas_futures(
14180
14231
  self,
@@ -14287,16 +14338,13 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14287
14338
  }
14288
14339
 
14289
14340
  except Exception as e:
14290
- context.log("warning", f"Modify futures order exception: {e}", node_id)
14291
- return {
14292
- "modify_result": {
14293
- "success": False,
14294
- "error": str(e),
14295
- "original_order_id": original_order_id,
14296
- "product": "overseas_futures",
14297
- },
14298
- "modified_order": None,
14299
- }
14341
+ from programgarden_core.exceptions import ExecutionError
14342
+ context.log("error", f"Modify futures order exception: {e}", node_id)
14343
+ raise ExecutionError(
14344
+ f"ModifyOrderNode failed to modify overseas_futures order "
14345
+ f"'{original_order_id}': {e}",
14346
+ node_id=node_id,
14347
+ ) from e
14300
14348
 
14301
14349
  async def _modify_korea_stock(
14302
14350
  self,
@@ -14389,16 +14437,13 @@ class ModifyOrderNodeExecutor(NodeExecutorBase):
14389
14437
  }
14390
14438
 
14391
14439
  except Exception as e:
14392
- context.log("warning", f"Korea stock modify order exception: {e}", node_id)
14393
- return {
14394
- "modify_result": {
14395
- "success": False,
14396
- "error": str(e),
14397
- "original_order_id": original_order_id,
14398
- "product": "korea_stock",
14399
- },
14400
- "modified_order": None,
14401
- }
14440
+ from programgarden_core.exceptions import ExecutionError
14441
+ context.log("error", f"Korea stock modify order exception: {e}", node_id)
14442
+ raise ExecutionError(
14443
+ f"ModifyOrderNode failed to modify korea_stock order "
14444
+ f"'{original_order_id}': {e}",
14445
+ node_id=node_id,
14446
+ ) from e
14402
14447
 
14403
14448
  def _error_result(self, error_msg: str) -> Dict[str, Any]:
14404
14449
  """에러 결과 반환"""
@@ -14456,7 +14501,9 @@ class CancelOrderNodeExecutor(NodeExecutorBase):
14456
14501
  )
14457
14502
  return {
14458
14503
  "order_id": order_id,
14504
+ "cancel_result": None,
14459
14505
  "cancelled_order_id": order_id,
14506
+ "cancelled_order": None,
14460
14507
  "status": "simulated",
14461
14508
  "dry_run": True,
14462
14509
  "requested": config,
@@ -15346,13 +15393,16 @@ class AIAgentNodeExecutor(NodeExecutorBase):
15346
15393
  ) -> Dict[str, Any]:
15347
15394
  import json as json_module
15348
15395
  from programgarden.providers import LLMProvider
15396
+ from programgarden_core.exceptions import ValidationError, ExecutionError
15349
15397
 
15350
- # === 0. deep_validate: 실 LLM/ReAct 루프를 절대 돌리지 않는다 ===
15398
+ # === 0. deep_validate / dry_run: 실 LLM/ReAct 루프를 절대 돌리지 않는다 ===
15351
15399
  # 가짜(스키마-shaped) response 를 주입해 다운스트림 {{ nodes.X.response[.field] }}
15352
- # 바인딩이 풀리고 flow 무결성이 검증되도록 한다(네트워크·모델비용 0). 실 LLM
15353
- # 호출 실패를 {"error":...} 삼키던 silent-fail 경로 자체를 제거한다.
15400
+ # 바인딩이 풀리고 flow 무결성이 검증되도록 한다(네트워크·모델비용 0).
15401
+ # dry_run 시뮬레이션 모드다(주문 노드가 dry_run 에서 시뮬레이션하는 것과 동일).
15402
+ # 실 LLM 호출 실패는 아래 실경로에서 **raise** 로 시끄럽게 처리하고(silent None 금지),
15403
+ # 시뮬레이션 모드에선 실 호출 자체를 안 하므로 여기서 fixture 로 단락한다.
15354
15404
  # preset 을 먼저 적용해 output_format/output_schema 가 런타임과 동일하게 반영되도록 함.
15355
- if getattr(context, "is_deep_validate", False):
15405
+ if getattr(context, "is_deep_validate", False) or getattr(context, "is_dry_run", False):
15356
15406
  from programgarden import deep_fixtures as _df
15357
15407
  _deep_config = config
15358
15408
  _preset_id = config.get("preset")
@@ -15376,12 +15426,21 @@ class AIAgentNodeExecutor(NodeExecutorBase):
15376
15426
  workflow = kwargs.get("workflow")
15377
15427
  if not workflow:
15378
15428
  context.log("error", "AIAgentNode requires workflow context", node_id)
15379
- return {"error": "Workflow context not provided"}
15429
+ # 하드 실패(설정 오류) raise. 에러-dict 로 굴러가면 하류가 침묵의 None 을
15430
+ # 먹고 워크플로우가 완주해 silent garbage 가 된다(§sweep 리워크).
15431
+ raise ValidationError(
15432
+ "AIAgentNode requires workflow context but none was provided.",
15433
+ node_id=node_id,
15434
+ )
15380
15435
 
15381
15436
  ai_model_node_id = workflow.get_ai_model_node_id(node_id)
15382
15437
  if not ai_model_node_id:
15383
15438
  context.log("error", "No LLMModelNode connected via ai_model edge", node_id)
15384
- return {"error": "No LLM model connected. Connect a LLMModelNode via ai_model edge."}
15439
+ raise ValidationError(
15440
+ "No LLM model connected. Connect a LLMModelNode to this AIAgentNode via an "
15441
+ "ai_model edge.",
15442
+ node_id=node_id,
15443
+ )
15385
15444
 
15386
15445
  # LLMModelNode의 출력에서 connection 가져오기
15387
15446
  llm_connection = context.get_output(ai_model_node_id, "connection")
@@ -15401,7 +15460,11 @@ class AIAgentNodeExecutor(NodeExecutorBase):
15401
15460
 
15402
15461
  if not llm_connection:
15403
15462
  context.log("error", "Failed to get LLM connection", node_id)
15404
- return {"error": "Failed to get LLM connection from LLMModelNode."}
15463
+ raise ValidationError(
15464
+ "Failed to get LLM connection from the connected LLMModelNode "
15465
+ "(check the model node's config/credentials).",
15466
+ node_id=node_id,
15467
+ )
15405
15468
 
15406
15469
  # secrets에서 API 키 복원 (H-8: 평문 노출 방지)
15407
15470
  llm_node_ref = llm_connection.get("_llm_node_id", ai_model_node_id)
@@ -15508,7 +15571,7 @@ class AIAgentNodeExecutor(NodeExecutorBase):
15508
15571
  )
15509
15572
  except Exception as e:
15510
15573
  context.log("error", f"LLM call failed: {e}", node_id)
15511
- return {"error": f"LLM call failed: {e}"}
15574
+ raise ExecutionError(f"AIAgentNode LLM call failed: {e}", node_id=node_id) from e
15512
15575
 
15513
15576
  # 토큰 사용량 이벤트
15514
15577
  from programgarden_core.bases.listener import TokenUsageEvent
@@ -15629,7 +15692,11 @@ class AIAgentNodeExecutor(NodeExecutorBase):
15629
15692
  ))
15630
15693
 
15631
15694
  if tool_error_strategy == "abort":
15632
- return {"error": f"Tool '{tool_name}' failed: {e}"}
15695
+ raise ExecutionError(
15696
+ f"AIAgentNode tool '{tool_name}' failed and tool_error_strategy="
15697
+ f"'abort': {e}",
15698
+ node_id=node_id,
15699
+ ) from e
15633
15700
  elif tool_error_strategy == "skip":
15634
15701
  messages.append({
15635
15702
  "role": "tool",
@@ -320,6 +320,12 @@ class WorkflowResolver:
320
320
  # 10.5 CodeNode 정적 검증 (compile/screen 사전검증 + credential 봉쇄)
321
321
  self._validate_code_nodes(workflow, result)
322
322
 
323
+ # 10.6 SplitNode 소스 검증 (분리할 배열이 실제로 배선됐는지)
324
+ self._validate_split_nodes(workflow, registry, result)
325
+
326
+ # 10.7 AIAgentNode ai_model 엣지 필수 검증 (LLM 없이는 애초에 동작 불가)
327
+ self._validate_ai_agent_nodes(workflow, result)
328
+
323
329
  # 11. Static recommendations (topology analysis)
324
330
  for rec in run_static_recommendation_rules(
325
331
  workflow,
@@ -1085,6 +1091,194 @@ class WorkflowResolver:
1085
1091
  )
1086
1092
  )
1087
1093
 
1094
+ # SplitNode 는 실 엔진에서 두 조건이 **모두** 충족돼야만 동작한다(6변형 dry_run
1095
+ # 프로브로 확정, executor._execute_split_branch / _execute_main_flow):
1096
+ # (1) 짝 AggregateNode 가 그래프상 도달 가능해야 한다. 없으면 _execute_split_branch
1097
+ # 가 aggregate_id=None 에서 즉시 return → branch 자체가 실행 안 됨(노드 pending
1098
+ # 유지, 하류 item 전부 공백). engine._find_split_aggregate_pairs 참조.
1099
+ # (2) split 로 들어오는 상류 노드가 리스트를 출력해야 한다. 엔진은 분리 대상 배열을
1100
+ # **오직 상류 노드 출력**(포트 symbols/values/array/data/items)에서만 읽는다.
1101
+ # config `array` 필드는 **어느 경로에서도 읽히지 않는다**(SplitNodeExecutor.execute
1102
+ # 의 config.get("array") 는 죽은 코드 — 실행 엔진이 그 executor 를 거치지 않고
1103
+ # 상류 출력에서 배열을 직접 가져와 item 을 set). 따라서 `array`/`items` 를 config
1104
+ # 리터럴로 넣는 저작 패턴은 검증만 통과하고 런타임엔 0개를 낸다.
1105
+ # 둘 중 하나라도 빠지면 하류 `{{ nodes.<split>.item }}` 이 조용히 비어 count:0 쓰레기
1106
+ # 결과가 된다(단일 심볼에 SplitNode 를 잘못 붙이는 것이 전형적 저작 결함). 정적으로
1107
+ # 잡아 AI self-correct 루프가 dry_run 전에 보게 한다.
1108
+ # 오탐(최대 리스크) 최소화: (2)는 상류 중 하나라도 리스트를 낼 "가능성"이 있으면 통과.
1109
+ # 가능성 = (a) 상류가 CodeNode(동적 출력), (b) 상류 스키마 미상(커뮤니티/제네릭),
1110
+ # (c) 상류 출력 포트 타입 중 하나라도 확정 스칼라/시그널이 아님.
1111
+ # 모든 상류가 확정 스칼라일 때만 flag.
1112
+ _SPLIT_SCALAR_OUTPUT_TYPES = frozenset({
1113
+ "signal", "boolean", "integer", "number", "float", "string",
1114
+ "broker_connection",
1115
+ })
1116
+
1117
+ def _validate_split_nodes(self, workflow, registry, result: ValidationResult) -> None:
1118
+ """SplitNode 가 실 엔진에서 동작 가능하게 배선됐는지 정적 검증(오탐 보수적).
1119
+
1120
+ 엔진 동작(프로브 확정): SplitNode 는 (1) 짝 AggregateNode 가 도달 가능하고
1121
+ (2) 상류가 리스트를 출력해야만 branch 를 실행한다. config `array` 는 안 읽힌다.
1122
+ """
1123
+ split_nodes = [
1124
+ n for n in workflow.nodes
1125
+ if isinstance(n, dict) and n.get("type") == "SplitNode"
1126
+ ]
1127
+ if not split_nodes:
1128
+ return
1129
+ node_type_by_id = {
1130
+ n.get("id"): n.get("type") for n in workflow.nodes if isinstance(n, dict)
1131
+ }
1132
+ aggregate_ids: Set[str] = {
1133
+ n.get("id") for n in workflow.nodes
1134
+ if isinstance(n, dict) and n.get("type") == "AggregateNode"
1135
+ }
1136
+ # adjacency(from_id -> [to_id]) + incoming(to_id -> [from_id])
1137
+ adjacency: Dict[str, List[str]] = {}
1138
+ incoming: Dict[str, List[str]] = {}
1139
+ for edge in workflow.edges:
1140
+ to_raw = getattr(edge, "to_node", "") or ""
1141
+ from_raw = getattr(edge, "from_node", "") or ""
1142
+ to_id = to_raw.split(".")[0] if to_raw else ""
1143
+ from_id = from_raw.split(".")[0] if from_raw else ""
1144
+ if to_id:
1145
+ incoming.setdefault(to_id, []).append(from_id)
1146
+ if from_id and to_id:
1147
+ adjacency.setdefault(from_id, []).append(to_id)
1148
+
1149
+ def _reaches_aggregate(start: Optional[str]) -> bool:
1150
+ # 엔진 _find_split_aggregate_pairs 와 동일하게 그래프 도달성으로 짝을 판정.
1151
+ if not start:
1152
+ return False
1153
+ seen: Set[str] = set()
1154
+ queue: List[str] = [start]
1155
+ while queue:
1156
+ cur = queue.pop(0)
1157
+ if cur in seen:
1158
+ continue
1159
+ seen.add(cur)
1160
+ for nxt in adjacency.get(cur, []):
1161
+ if nxt in aggregate_ids:
1162
+ return True
1163
+ queue.append(nxt)
1164
+ return False
1165
+
1166
+ def _may_produce_list(src_type: Optional[str]) -> bool:
1167
+ # 동적/미상 출력은 리스트 가능 → 보수적으로 통과.
1168
+ if not src_type or src_type == "CodeNode":
1169
+ return True
1170
+ schema = registry.get_schema(src_type)
1171
+ if schema is None:
1172
+ return True
1173
+ ports = list(schema.outputs or [])
1174
+ if not ports:
1175
+ return True # 출력 미선언 → 알 수 없음 → 통과
1176
+ for out in ports:
1177
+ ptype = out.get("type") if isinstance(out, dict) else getattr(out, "type", None)
1178
+ if ptype not in self._SPLIT_SCALAR_OUTPUT_TYPES:
1179
+ return True # 배열/오브젝트 등 비스칼라 포트 존재 → 가능성 있음
1180
+ return False # 모든 포트가 확정 스칼라 → 리스트 생산 불가
1181
+
1182
+ for node in split_nodes:
1183
+ sid = node.get("id")
1184
+ # (1) 짝 AggregateNode 도달성 — 없으면 엔진이 branch 를 실행조차 안 한다.
1185
+ if not _reaches_aggregate(sid):
1186
+ result.add(
1187
+ build_error(
1188
+ ErrorCode.MISSING_REQUIRED_FIELD,
1189
+ (
1190
+ f"SplitNode '{sid}' has no reachable AggregateNode. The engine "
1191
+ f"only runs a SplitNode's per-item branch when a paired "
1192
+ f"AggregateNode is reachable from it — without one the branch "
1193
+ f"never executes (the node stays pending) and every downstream "
1194
+ f"`{{{{ nodes.{sid}.item }}}}` resolves empty (silent count:0)."
1195
+ ),
1196
+ location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path=None),
1197
+ suggestion=(
1198
+ "Add an AggregateNode downstream of this split's branch (so it is "
1199
+ "reachable from the SplitNode), which recollects the per-item "
1200
+ "results. If you only ever have a single value, DELETE the "
1201
+ "SplitNode and bind that value directly on the downstream node."
1202
+ ),
1203
+ )
1204
+ )
1205
+ continue
1206
+ # (2) 상류 리스트 소스 — config `array` 는 엔진이 안 읽으므로 소스로 인정 안 함.
1207
+ srcs = incoming.get(sid, [])
1208
+ if srcs and any(_may_produce_list(node_type_by_id.get(s)) for s in srcs):
1209
+ continue
1210
+ result.add(
1211
+ build_error(
1212
+ ErrorCode.MISSING_REQUIRED_FIELD,
1213
+ (
1214
+ f"SplitNode '{sid}' has no upstream node that outputs a list. The "
1215
+ f"engine reads the array to split ONLY from an upstream node's list "
1216
+ f"output (ports symbols/values/array/data/items) — the `array` "
1217
+ f"config field is NOT read at runtime. It will emit zero items and "
1218
+ f"every downstream `{{{{ nodes.{sid}.item }}}}` resolves empty "
1219
+ f"(silent count:0 result)."
1220
+ ),
1221
+ location=ErrorLocation(node_id=sid, node_type="SplitNode", field_path="array"),
1222
+ suggestion=(
1223
+ "Connect an edge from an upstream node that OUTPUTS a list "
1224
+ "(WatchlistNode/MarketUniverseNode → symbols, AccountNode → "
1225
+ "positions, ConditionNode → values, or a CodeNode returning a list). "
1226
+ "Do NOT put the list in an `array`/`items` config field — the engine "
1227
+ "ignores it. For a single known symbol, DELETE SplitNode and bind "
1228
+ "that symbol directly on the downstream node."
1229
+ ),
1230
+ )
1231
+ )
1232
+
1233
+ def _validate_ai_agent_nodes(self, workflow, result: ValidationResult) -> None:
1234
+ """AIAgentNode 는 ai_model 엣지로 LLMModelNode 가 연결돼야 한다.
1235
+
1236
+ LLM 이 없으면 이 노드는 **애초에 동작 불가**(런타임에 raise)이므로 오탐 위험이
1237
+ 0이다. static validate 에서 잡아 챗봇이 저장 전에 고치게 한다 — deep_validate 는
1238
+ 인프라 오류 시 검증을 건너뛰고 '성공'으로 저장할 수 있어 못 믿는다(soft-skip).
1239
+ ai_model 엣지의 source/target **형태** 오류는 _validate_edge_references 가
1240
+ (INVALID_AI_MODEL_EDGE) 별도로 잡으므로, 여기선 '연결 자체가 없는' 구멍만 본다.
1241
+ """
1242
+ from programgarden_core.models.edge import EdgeType
1243
+
1244
+ agent_ids = [
1245
+ n.get("id")
1246
+ for n in workflow.nodes
1247
+ if isinstance(n, dict) and n.get("type") == "AIAgentNode"
1248
+ ]
1249
+ if not agent_ids:
1250
+ return
1251
+ # ai_model 엣지가 타깃으로 삼는 AIAgentNode id 집합
1252
+ ai_model_targets: Set[str] = set()
1253
+ for edge in workflow.edges:
1254
+ edge_type = getattr(edge, "edge_type", None)
1255
+ et = (
1256
+ edge_type.value if hasattr(edge_type, "value")
1257
+ else (str(edge_type) if edge_type else "main")
1258
+ )
1259
+ if et != EdgeType.AI_MODEL.value:
1260
+ continue
1261
+ to_raw = getattr(edge, "to_node", "") or ""
1262
+ to_id = to_raw.split(".")[0] if to_raw else ""
1263
+ if to_id:
1264
+ ai_model_targets.add(to_id)
1265
+
1266
+ for aid in agent_ids:
1267
+ if aid in ai_model_targets:
1268
+ continue
1269
+ result.add(
1270
+ build_error(
1271
+ ErrorCode.MISSING_REQUIRED_FIELD,
1272
+ f"AIAgentNode '{aid}' has no LLM model connected — it cannot run without "
1273
+ f"one. Connect an LLMModelNode to '{aid}' via an \"ai_model\" edge.",
1274
+ location=ErrorLocation(node_id=aid, node_type="AIAgentNode", field_path="ai_model"),
1275
+ suggestion=(
1276
+ "Add an LLMModelNode and wire it with an ai_model edge, e.g. "
1277
+ f'{{"from": "<llm_node_id>", "to": "{aid}", "edge_type": "ai_model"}}.'
1278
+ ),
1279
+ )
1280
+ )
1281
+
1088
1282
  def _validate_credential_references(
1089
1283
  self,
1090
1284
  workflow,
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.26.0"
8
+ version = "1.27.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes