programgarden 1.29.3__tar.gz → 1.30.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.29.3 → programgarden-1.30.0}/PKG-INFO +2 -2
  2. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/executor.py +207 -92
  3. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/semantic_rules.py +99 -4
  4. {programgarden-1.29.3 → programgarden-1.30.0}/pyproject.toml +2 -2
  5. {programgarden-1.29.3 → programgarden-1.30.0}/README.md +0 -0
  6. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/__init__.py +0 -0
  7. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/binding_validator.py +0 -0
  8. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/client.py +0 -0
  9. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/code_worker.py +0 -0
  10. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/context.py +0 -0
  11. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/__init__.py +0 -0
  12. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/checkpoint_manager.py +0 -0
  13. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/query_builder.py +0 -0
  14. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_position_tracker.py +0 -0
  15. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_risk_tracker.py +0 -0
  16. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/deep_fixtures.py +0 -0
  17. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/node_runner.py +0 -0
  18. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/plugin/__init__.py +0 -0
  19. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/plugin/sandbox.py +0 -0
  20. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/__init__.py +0 -0
  21. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/llm_errors.py +0 -0
  22. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/llm_provider.py +0 -0
  23. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/reconnect_handler.py +0 -0
  24. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resolver.py +0 -0
  25. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/__init__.py +0 -0
  26. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/context.py +0 -0
  27. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/limiter.py +0 -0
  28. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/monitor.py +0 -0
  29. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/throttle.py +0 -0
  30. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.29.3
3
+ Version: 1.30.0
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,7 +16,7 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
18
  Requires-Dist: programgarden-community (>=1.13.11,<2.0.0)
19
- Requires-Dist: programgarden-core (>=1.21.0,<2.0.0)
19
+ Requires-Dist: programgarden-core (>=1.22.0,<2.0.0)
20
20
  Requires-Dist: programgarden-finance (>=1.6.14,<2.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
@@ -15,6 +15,7 @@ import ast
15
15
  import re
16
16
  import uuid
17
17
  import logging
18
+ import sys
18
19
 
19
20
  from programgarden.resolver import WorkflowResolver, ResolvedWorkflow, ValidationResult
20
21
  from programgarden_core import (
@@ -42,6 +43,44 @@ from programgarden_core.nodes.base import BaseMessagingNode
42
43
  logger = logging.getLogger("programgarden.executor")
43
44
 
44
45
 
46
+ def _safe_print(*args: Any, **kwargs: Any) -> None:
47
+ """Emit console output without ever letting it abort the caller.
48
+
49
+ Console output is a side effect — a workflow must never die because a log
50
+ line could not be rendered. On a console whose encoding cannot represent
51
+ the text (cp949 on Korean Windows is the case we hit), a bare ``print``
52
+ raises ``UnicodeEncodeError``. Two things make that fatal rather than
53
+ cosmetic:
54
+
55
+ 1. The progress line at the head of ``_execute_main_flow`` runs before any
56
+ node does, so the whole job dies before it starts.
57
+ 2. The same call pattern sits inside ``except`` blocks, so the *reporting*
58
+ path raises too and the original cause is swallowed — the failure never
59
+ reaches ``notify_job_state`` and vanishes silently.
60
+
61
+ Note that ASCII-only literals are not sufficient protection: interpolated
62
+ values (node names, exception messages) can themselves carry characters the
63
+ console cannot encode. So every print in this module goes through here.
64
+ """
65
+ try:
66
+ print(*args, **kwargs)
67
+ except UnicodeEncodeError:
68
+ # Re-render through the stream's own encoding so the line still lands,
69
+ # with unrepresentable characters replaced rather than the job lost.
70
+ try:
71
+ stream = kwargs.get("file") or sys.stdout
72
+ encoding = getattr(stream, "encoding", None) or "ascii"
73
+ sep = kwargs.get("sep", " ")
74
+ end = kwargs.get("end", "\n")
75
+ text = sep.join(str(a) for a in args) + end
76
+ stream.write(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
77
+ except Exception:
78
+ logger.debug("console output dropped", exc_info=True)
79
+ except Exception:
80
+ # Logging is never load-bearing.
81
+ logger.debug("console output dropped", exc_info=True)
82
+
83
+
45
84
  # lib reserved iteration variable roots — valid ONLY mid-iteration.
46
85
  # `{{ item.* }}` (auto-iterate per-item) and `{{ row.* }}` (ConditionNode
47
86
  # items.extract) only resolve while the executor has an iteration context set;
@@ -1086,10 +1125,22 @@ def _pick_split_array(upstream: Dict[str, Any], *, source: str) -> List[Any]:
1086
1125
  if len(candidates) == 1:
1087
1126
  return upstream[candidates[0]]
1088
1127
  if not candidates:
1128
+ # ⑰ Diagnostic: a singular alias (value → values, item → items) present
1129
+ # where the plural array port was meant is a common wrong-key mistake;
1130
+ # surface it in the error so the fix is obvious (report-only — resolution
1131
+ # is unchanged, the two ports legitimately co-exist).
1132
+ _alias_hint = ""
1133
+ for _sing, _plur in (("value", "values"), ("item", "items")):
1134
+ if _sing in upstream and _plur not in upstream:
1135
+ _alias_hint = (
1136
+ f" Upstream has '{_sing}' (singular) but no '{_plur}' (array) "
1137
+ f"— did you mean '{_plur}'?"
1138
+ )
1139
+ break
1089
1140
  raise RuntimeError(
1090
1141
  f"SplitNode has no array to split: upstream ({source}) exposes no "
1091
- f"array output among {_SPLIT_ARRAY_KEYS}. Bind split.array explicitly "
1092
- "(e.g. array: '{{ nodes.<id>.held_symbols }}') or wire an "
1142
+ f"array output among {_SPLIT_ARRAY_KEYS}.{_alias_hint} Bind split.array "
1143
+ "explicitly (e.g. array: '{{ nodes.<id>.held_symbols }}') or wire an "
1093
1144
  "array-producing node upstream."
1094
1145
  )
1095
1146
  raise RuntimeError(
@@ -1509,10 +1560,26 @@ class ScheduleNodeExecutor(NodeExecutorBase):
1509
1560
  context.log("debug", f"Scheduler already running for {node_id}, skip", node_id)
1510
1561
  return {"trigger": True}
1511
1562
 
1512
- cron_expr = config.get("cron", "*/5 * * * *")
1563
+ # ⑭ cron is required — NO silent "*/5 * * * *" fallback. A missing cron
1564
+ # used to make the schedule quietly run every 5 minutes ("looks fine,
1565
+ # behaves differently"). Fail loudly instead so the real cause surfaces.
1566
+ cron_expr = config.get("cron")
1567
+ if not isinstance(cron_expr, str) or not cron_expr.strip():
1568
+ context.log(
1569
+ "error",
1570
+ "ScheduleNode requires a 'cron' expression; there is no default schedule fallback",
1571
+ node_id,
1572
+ )
1573
+ raise ValueError(
1574
+ f"ScheduleNode '{node_id}' requires a 'cron' expression; there is no "
1575
+ f"default schedule fallback. Set cron explicitly, e.g. '*/5 * * * *' "
1576
+ f"(every 5 min) or '30 9 * * 1-5' (09:30 weekdays). Keys like "
1577
+ f"'mode'/'schedule' are not read by this node."
1578
+ )
1579
+ cron_expr = cron_expr.strip()
1513
1580
  tz_name = config.get("timezone", "America/New_York")
1514
1581
  enabled = config.get("enabled", True)
1515
- count = config.get("count", 1000) # 최대 반복 횟수 (M-6: 기본값 축소)
1582
+ count = config.get("count", 1000) # 최대 반복 횟수 (M-6: 기본값 축소; 모델 필드로 편입)
1516
1583
  max_duration_hours = config.get("max_duration_hours", 24.0)
1517
1584
 
1518
1585
  if not enabled:
@@ -8396,8 +8463,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8396
8463
 
8397
8464
  if chart_type == "summary":
8398
8465
  # Raw JSON 표시
8399
- print(f"\n📋 {title or 'Data Summary'} [{now}]")
8400
- print("=" * 60)
8466
+ _safe_print(f"\n📋 {title or 'Data Summary'} [{now}]")
8467
+ _safe_print("=" * 60)
8401
8468
  try:
8402
8469
  def serialize(obj):
8403
8470
  if hasattr(obj, 'model_dump'):
@@ -8411,11 +8478,11 @@ class DisplayNodeExecutor(NodeExecutorBase):
8411
8478
  return obj
8412
8479
 
8413
8480
  serialized = serialize(data)
8414
- print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8481
+ _safe_print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8415
8482
  except Exception as e:
8416
- print(f"(직렬화 실패: {e})")
8417
- print(str(data)[:500])
8418
- print("=" * 60 + "\n")
8483
+ _safe_print(f"(직렬화 실패: {e})")
8484
+ _safe_print(str(data)[:500])
8485
+ _safe_print("=" * 60 + "\n")
8419
8486
 
8420
8487
  output_data["data"] = data
8421
8488
 
@@ -8426,8 +8493,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8426
8493
  sort_by = config.get("sort_by")
8427
8494
  sort_order = config.get("sort_order", "desc")
8428
8495
 
8429
- print(f"\n📊 {title or 'Table'} [{now}]")
8430
- print("=" * 80)
8496
+ _safe_print(f"\n📊 {title or 'Table'} [{now}]")
8497
+ _safe_print("=" * 80)
8431
8498
 
8432
8499
  # 데이터 형식 판단
8433
8500
  if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
@@ -8441,13 +8508,13 @@ class DisplayNodeExecutor(NodeExecutorBase):
8441
8508
  (k, k) for k in list(rows[0].keys())[:8]
8442
8509
  ]
8443
8510
  header = " | ".join(f"{label:<12}" for _, label in cols)
8444
- print(header)
8445
- print("-" * 80)
8511
+ _safe_print(header)
8512
+ _safe_print("-" * 80)
8446
8513
  for row in rows:
8447
8514
  values = " | ".join(
8448
8515
  f"{self._format_value(row.get(key)):<12}" for key, _ in cols
8449
8516
  )
8450
- print(values)
8517
+ _safe_print(values)
8451
8518
 
8452
8519
  elif isinstance(data, dict):
8453
8520
  # dict of dicts (symbol: {field: value}) 또는 flat dict
@@ -8463,19 +8530,19 @@ class DisplayNodeExecutor(NodeExecutorBase):
8463
8530
  (k, k) for k in list(items[0][1].keys())[:6]
8464
8531
  ]
8465
8532
  header = f"{'Key':<12} | " + " | ".join(f"{label:<12}" for _, label in cols)
8466
- print(header)
8467
- print("-" * 80)
8533
+ _safe_print(header)
8534
+ _safe_print("-" * 80)
8468
8535
  for key, val in items:
8469
8536
  values = f"{key:<12} | " + " | ".join(
8470
8537
  f"{self._format_value(val.get(col_key)):<12}" for col_key, _ in cols
8471
8538
  )
8472
- print(values)
8539
+ _safe_print(values)
8473
8540
  else:
8474
8541
  # flat dict
8475
8542
  for k, v in list(data.items())[:limit]:
8476
- print(f" {k:<25}: {self._format_value(v)}")
8543
+ _safe_print(f" {k:<25}: {self._format_value(v)}")
8477
8544
 
8478
- print("=" * 80 + "\n")
8545
+ _safe_print("=" * 80 + "\n")
8479
8546
  output_data["data"] = data
8480
8547
 
8481
8548
  elif chart_type == "line":
@@ -8489,36 +8556,36 @@ class DisplayNodeExecutor(NodeExecutorBase):
8489
8556
  context.log("error", f"line 차트에 x_field, y_field 필수", node_id)
8490
8557
  return {"rendered": False, "error": "x_field, y_field 필수"}
8491
8558
 
8492
- print(f"\n📈 {title or 'Line Chart'} [{now}]")
8493
- print("=" * 60)
8559
+ _safe_print(f"\n📈 {title or 'Line Chart'} [{now}]")
8560
+ _safe_print("=" * 60)
8494
8561
 
8495
8562
  if isinstance(data, list) and len(data) > 0:
8496
8563
  first = data[0]
8497
8564
  last = data[-1]
8498
- print(f"📊 데이터 포인트: {len(data)}개")
8499
- print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
8500
- print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
8501
- print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
8565
+ _safe_print(f"📊 데이터 포인트: {len(data)}개")
8566
+ _safe_print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
8567
+ _safe_print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
8568
+ _safe_print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
8502
8569
 
8503
8570
  # 최대/최소
8504
8571
  values = [d.get(y_field, 0) for d in data if d.get(y_field) is not None]
8505
8572
  if values:
8506
- print(f"📈 최대: {self._format_value(max(values))}")
8507
- print(f"📉 최소: {self._format_value(min(values))}")
8573
+ _safe_print(f"📈 최대: {self._format_value(max(values))}")
8574
+ _safe_print(f"📉 최소: {self._format_value(min(values))}")
8508
8575
 
8509
8576
  # 시그널 마커 표시
8510
8577
  if signal_field:
8511
8578
  signals = self._extract_signals(data, x_field, signal_field, side_field)
8512
8579
  if signals:
8513
- print(f"\n🎯 시그널 ({len(signals)}개):")
8580
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8514
8581
  for sig in signals[:10]: # 최대 10개만 표시
8515
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8582
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8516
8583
  if len(signals) > 10:
8517
- print(f" ... 외 {len(signals) - 10}개")
8584
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8518
8585
  else:
8519
- print("(데이터 없음)")
8586
+ _safe_print("(데이터 없음)")
8520
8587
 
8521
- print("=" * 60 + "\n")
8588
+ _safe_print("=" * 60 + "\n")
8522
8589
  output_data["data"] = data
8523
8590
  output_data["x_field"] = x_field
8524
8591
  output_data["y_field"] = y_field
@@ -8542,8 +8609,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
8542
8609
  context.log("error", f"multi_line 차트에 x_field, y_field, series_key 필수", node_id)
8543
8610
  return {"rendered": False, "error": "x_field, y_field, series_key 필수"}
8544
8611
 
8545
- print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
8546
- print("=" * 80)
8612
+ _safe_print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
8613
+ _safe_print("=" * 80)
8547
8614
 
8548
8615
  # data가 평탄화된 배열 형식인 경우 [{symbol, date, value, ...}, ...]
8549
8616
  if isinstance(data, list):
@@ -8568,29 +8635,29 @@ class DisplayNodeExecutor(NodeExecutorBase):
8568
8635
 
8569
8636
  series_items = series_items[:limit]
8570
8637
 
8571
- print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
8572
- print("-" * 80)
8573
- print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
8574
- print("-" * 80)
8638
+ _safe_print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
8639
+ _safe_print("-" * 80)
8640
+ _safe_print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
8641
+ _safe_print("-" * 80)
8575
8642
 
8576
8643
  for symbol, rows in series_items:
8577
8644
  if isinstance(rows, list) and len(rows) > 0:
8578
8645
  first_val = rows[0].get(y_field, 0)
8579
8646
  last_val = rows[-1].get(y_field, 0)
8580
- print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
8647
+ _safe_print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
8581
8648
 
8582
8649
  # 시그널 마커 표시
8583
8650
  if signal_field:
8584
8651
  signals = self._extract_signals(data, x_field, signal_field, side_field, series_key)
8585
8652
  if signals:
8586
- print(f"\n🎯 시그널 ({len(signals)}개):")
8653
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8587
8654
  for sig in signals[:15]: # 최대 15개만 표시
8588
8655
  series_info = f" [{sig.get('series', '')}]" if sig.get('series') else ""
8589
- print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8656
+ _safe_print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8590
8657
  if len(signals) > 15:
8591
- print(f" ... 외 {len(signals) - 15}개")
8658
+ _safe_print(f" ... 외 {len(signals) - 15}개")
8592
8659
 
8593
- print("=" * 80 + "\n")
8660
+ _safe_print("=" * 80 + "\n")
8594
8661
  output_data["data"] = data
8595
8662
  output_data["x_field"] = x_field
8596
8663
  output_data["y_field"] = y_field
@@ -8616,16 +8683,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
8616
8683
  context.log("error", f"candlestick 차트에 date_field, open_field, high_field, low_field, close_field 필수", node_id)
8617
8684
  return {"rendered": False, "error": "OHLC 필드 필수"}
8618
8685
 
8619
- print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
8620
- print("=" * 80)
8686
+ _safe_print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
8687
+ _safe_print("=" * 80)
8621
8688
 
8622
8689
  if isinstance(data, list) and len(data) > 0:
8623
8690
  first = data[0]
8624
8691
  last = data[-1]
8625
- print(f"📊 캔들 수: {len(data)}개")
8626
- print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
8627
- print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
8628
- print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
8692
+ _safe_print(f"📊 캔들 수: {len(data)}개")
8693
+ _safe_print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
8694
+ _safe_print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
8695
+ _safe_print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
8629
8696
 
8630
8697
  # 수익률
8631
8698
  start_price = first.get(close_field, 0)
@@ -8634,21 +8701,21 @@ class DisplayNodeExecutor(NodeExecutorBase):
8634
8701
  pct_change = (end_price - start_price) / start_price * 100
8635
8702
  sign = "+" if pct_change >= 0 else ""
8636
8703
  color = "\033[92m" if pct_change >= 0 else "\033[91m"
8637
- print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
8704
+ _safe_print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
8638
8705
 
8639
8706
  # 시그널 마커 표시
8640
8707
  if signal_field:
8641
8708
  signals = self._extract_signals(data, date_field, signal_field, side_field)
8642
8709
  if signals:
8643
- print(f"\n🎯 시그널 ({len(signals)}개):")
8710
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8644
8711
  for sig in signals[:10]: # 최대 10개만 표시
8645
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8712
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8646
8713
  if len(signals) > 10:
8647
- print(f" ... 외 {len(signals) - 10}개")
8714
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8648
8715
  else:
8649
- print("(데이터 없음)")
8716
+ _safe_print("(데이터 없음)")
8650
8717
 
8651
- print("=" * 80 + "\n")
8718
+ _safe_print("=" * 80 + "\n")
8652
8719
  output_data["data"] = data
8653
8720
  output_data["date_field"] = date_field
8654
8721
  output_data["open_field"] = open_field
@@ -8671,23 +8738,23 @@ class DisplayNodeExecutor(NodeExecutorBase):
8671
8738
  context.log("error", f"bar 차트에 x_field, y_field 필수", node_id)
8672
8739
  return {"rendered": False, "error": "x_field, y_field 필수"}
8673
8740
 
8674
- print(f"\n📊 {title or 'Bar Chart'} [{now}]")
8675
- print("=" * 60)
8741
+ _safe_print(f"\n📊 {title or 'Bar Chart'} [{now}]")
8742
+ _safe_print("=" * 60)
8676
8743
 
8677
8744
  if isinstance(data, list) and len(data) > 0:
8678
- print(f"{'Bar':<20} | {y_field}")
8679
- print("-" * 60)
8745
+ _safe_print(f"{'Bar':<20} | {y_field}")
8746
+ _safe_print("-" * 60)
8680
8747
  for item in data[:20]:
8681
8748
  x_val = item.get(x_field, "")
8682
8749
  y_val = item.get(y_field, 0)
8683
- print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8750
+ _safe_print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8684
8751
  elif isinstance(data, dict):
8685
- print(f"{'Key':<20} | {'Value'}")
8686
- print("-" * 60)
8752
+ _safe_print(f"{'Key':<20} | {'Value'}")
8753
+ _safe_print("-" * 60)
8687
8754
  for k, v in list(data.items())[:20]:
8688
- print(f"{str(k):<20} | {self._format_value(v)}")
8755
+ _safe_print(f"{str(k):<20} | {self._format_value(v)}")
8689
8756
 
8690
- print("=" * 60 + "\n")
8757
+ _safe_print("=" * 60 + "\n")
8691
8758
  output_data["data"] = data
8692
8759
  output_data["x_field"] = x_field
8693
8760
  output_data["y_field"] = y_field
@@ -10555,7 +10622,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10555
10622
  except (ValueError, _json.JSONDecodeError):
10556
10623
  pass
10557
10624
 
10558
- # 우선순위: config.symbol > input.symbol
10625
+ # 우선순위: config.symbol > input.symbol > config.symbols (수동 목록 폴백)
10559
10626
  if config_symbol:
10560
10627
  symbols_raw = [config_symbol] if isinstance(config_symbol, dict) else []
10561
10628
  context.log("debug", f"Using config.symbol: {config_symbol}", node_id)
@@ -10563,7 +10630,23 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
10563
10630
  symbols_raw = [input_symbol] if isinstance(input_symbol, dict) else []
10564
10631
  context.log("debug", f"Using input port symbol: {input_symbol}", node_id)
10565
10632
  else:
10566
- symbols_raw = []
10633
+ # 마지막 폴백: config.symbols (복수 리터럴 목록) — MarketDataNodeExecutor 의
10634
+ # config_symbols 폴백과 대칭. 상류 배열 입력은 메인 루프 auto-iterate 가
10635
+ # 단수 input.symbol 로 풀어 위 분기에서 소화되므로, 여기 도달하는 건
10636
+ # 리터럴 목록뿐이라 N² 재조회 우려가 없다. deep_validate 픽스처는 이미
10637
+ # _config_symbols() 로 plural 을 읽어 라이브만 조용히 무시하던 비대칭이었다
10638
+ # (실측: code-node 예제 87~93 이 심볼 3개를 적고도 빈 ohlcv 로 사망).
10639
+ config_symbols = config.get("symbols")
10640
+ if isinstance(config_symbols, list):
10641
+ symbols_raw = [e for e in config_symbols if isinstance(e, (dict, str))]
10642
+ if symbols_raw:
10643
+ context.log(
10644
+ "debug",
10645
+ f"Using config.symbols list: {len(symbols_raw)} entries",
10646
+ node_id,
10647
+ )
10648
+ else:
10649
+ symbols_raw = []
10567
10650
 
10568
10651
  # symbols 정규화: [{exchange, symbol}] → 문자열 리스트 + exchange 정보 보존
10569
10652
  symbols = []
@@ -12347,7 +12430,21 @@ class PositionSizingNodeExecutor(NodeExecutorBase):
12347
12430
  price_data = evaluated.get("price_data") or evaluated.get("prices") or evaluated.get("market_data") or {}
12348
12431
 
12349
12432
  # 설정 추출
12350
- method = evaluated.get("method", "fixed_percent")
12433
+ # ⑰ method is required — NO silent "fixed_percent" fallback. The old
12434
+ # default quietly confirmed a hallucinated key (e.g. size_type:"fixed"
12435
+ # left method unset → sized as fixed_percent). Fail loudly instead.
12436
+ method = evaluated.get("method")
12437
+ if not method:
12438
+ context.log(
12439
+ "error",
12440
+ "PositionSizingNode requires 'method'; there is no default fallback",
12441
+ node_id,
12442
+ )
12443
+ raise ValueError(
12444
+ f"PositionSizingNode '{node_id}' requires 'method' (one of "
12445
+ f"fixed_percent, fixed_amount, fixed_quantity, kelly, atr_based); "
12446
+ f"there is no default. A key like 'size_type' is not read by this node."
12447
+ )
12351
12448
  max_percent = float(evaluated.get("max_percent", 10.0))
12352
12449
  fixed_amount = evaluated.get("fixed_amount")
12353
12450
  fixed_quantity = int(evaluated.get("fixed_quantity", 1))
@@ -18203,7 +18300,7 @@ class WorkflowJob:
18203
18300
  except asyncio.CancelledError:
18204
18301
  self.status = "cancelled"
18205
18302
  logger.info(f"Job {self.job_id} cancelled")
18206
- print(f"⚠️ Job {self.job_id} cancelled")
18303
+ _safe_print(f"[CANCELLED] Job {self.job_id} cancelled")
18207
18304
  await self.context.notify_job_state("cancelled", self.stats)
18208
18305
  except Exception as e:
18209
18306
  self.status = "failed"
@@ -18220,9 +18317,17 @@ class WorkflowJob:
18220
18317
  }
18221
18318
  self.context.log("error", error_msg)
18222
18319
  logger.exception(f"Job {self.job_id} failed: {e}")
18223
- print(f"❌ Job {self.job_id} failed: {e}")
18320
+ # ASCII-only marker: this is the failure-reporting path, so it must
18321
+ # survive a console that could not render the text that failed.
18322
+ _safe_print(f"[FAILED] Job {self.job_id} failed: {e}")
18224
18323
  import traceback
18225
- traceback.print_exc()
18324
+ try:
18325
+ # print_exc renders the offending source line, which may itself
18326
+ # hold the character that could not be encoded — so it can raise
18327
+ # the very error it is reporting.
18328
+ traceback.print_exc()
18329
+ except Exception:
18330
+ logger.debug("traceback output dropped", exc_info=True)
18226
18331
  await self.context.notify_job_state("failed", self.stats)
18227
18332
 
18228
18333
  # WORKFLOW_FAILED notification
@@ -18296,7 +18401,7 @@ class WorkflowJob:
18296
18401
  skip_nodes: 복구 모드에서 이미 완료된 노드 ID 집합 (스킵)
18297
18402
  """
18298
18403
  skip_nodes = skip_nodes or set()
18299
- print(f"🔄 Executing main flow: {self.workflow.execution_order}")
18404
+ _safe_print(f"🔄 Executing main flow: {self.workflow.execution_order}")
18300
18405
 
18301
18406
  # === Item-based execution setup ===
18302
18407
  # Find Split/Aggregate pairs and their branch nodes
@@ -18336,7 +18441,7 @@ class WorkflowJob:
18336
18441
 
18337
18442
  # === Checkpoint restore: 이미 완료된 노드 스킵 ===
18338
18443
  if node_id in skip_nodes:
18339
- print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
18444
+ _safe_print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
18340
18445
  self._completed_node_ids.add(node_id)
18341
18446
  await self.context.notify_node_state(
18342
18447
  node_id=node_id,
@@ -18347,7 +18452,7 @@ class WorkflowJob:
18347
18452
 
18348
18453
  # === Item-based execution: Skip branch nodes (handled by SplitNode) ===
18349
18454
  if node_id in branch_nodes and node.node_type not in ("SplitNode", "AggregateNode"):
18350
- print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
18455
+ _safe_print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
18351
18456
  continue
18352
18457
 
18353
18458
  # === Item-based execution: Handle SplitNode specially ===
@@ -18358,12 +18463,12 @@ class WorkflowJob:
18358
18463
 
18359
18464
  # === Item-based execution: Skip AggregateNode (already executed by SplitNode) ===
18360
18465
  if node.node_type == "AggregateNode" and node_id in split_aggregate_pairs.values():
18361
- print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
18466
+ _safe_print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
18362
18467
  continue
18363
18468
 
18364
18469
  # === IfNode: 비활성 브랜치 스킵 ===
18365
18470
  if node_id in if_skipped_nodes:
18366
- print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
18471
+ _safe_print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
18367
18472
  await self.context.notify_node_state(
18368
18473
  node_id=node_id,
18369
18474
  node_type=node.node_type,
@@ -18371,7 +18476,7 @@ class WorkflowJob:
18371
18476
  )
18372
18477
  continue
18373
18478
 
18374
- print(f" ▶ Executing node: {node_id} ({node.node_type})")
18479
+ _safe_print(f" ▶ Executing node: {node_id} ({node.node_type})")
18375
18480
 
18376
18481
  # 🆕 노드 실행 시작 알림
18377
18482
  start_time = datetime.utcnow()
@@ -18542,7 +18647,7 @@ class WorkflowJob:
18542
18647
  new_skips = self._compute_if_skip_nodes(node_id, taken)
18543
18648
  if_skipped_nodes.update(new_skips)
18544
18649
  if new_skips:
18545
- print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
18650
+ _safe_print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
18546
18651
 
18547
18652
  # deep_validate: a node that *returns* a sole-`error` dict (rather
18548
18653
  # than raising) would otherwise be stored as a COMPLETED output and
@@ -18843,7 +18948,7 @@ class WorkflowJob:
18843
18948
  all_results = []
18844
18949
  total = len(items)
18845
18950
 
18846
- print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
18951
+ _safe_print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
18847
18952
 
18848
18953
  for idx, current_item in enumerate(items):
18849
18954
  # === item, index, total을 ExecutionContext에 설정 ===
@@ -18854,7 +18959,7 @@ class WorkflowJob:
18854
18959
 
18855
18960
  # 진행 상황 로그
18856
18961
  item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
18857
- print(f" [{idx+1}/{total}] Processing: {item_label}")
18962
+ _safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
18858
18963
  self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
18859
18964
 
18860
18965
  # A-3: per-item spacing for order / external-API nodes
@@ -18887,7 +18992,7 @@ class WorkflowJob:
18887
18992
 
18888
18993
  # 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
18889
18994
  merged = self._merge_iterate_results(all_results)
18890
- print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
18995
+ _safe_print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
18891
18996
 
18892
18997
  return merged
18893
18998
 
@@ -19081,7 +19186,7 @@ class WorkflowJob:
19081
19186
  """
19082
19187
  from programgarden_core.bases.listener import NodeState
19083
19188
 
19084
- print(f" 🔀 Executing SplitNode branch: {split_id}")
19189
+ _safe_print(f" 🔀 Executing SplitNode branch: {split_id}")
19085
19190
 
19086
19191
  # Get paired AggregateNode
19087
19192
  aggregate_id = split_aggregate_pairs.get(split_id)
@@ -19170,7 +19275,7 @@ class WorkflowJob:
19170
19275
  results_by_index: Dict[int, Any] = {}
19171
19276
  errors: List[str] = []
19172
19277
 
19173
- print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
19278
+ _safe_print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
19174
19279
 
19175
19280
  # === Execute branch for each item ===
19176
19281
  if parallel:
@@ -19417,7 +19522,7 @@ class WorkflowJob:
19417
19522
  if not node:
19418
19523
  return
19419
19524
 
19420
- print(f" 🔗 Executing AggregateNode: {aggregate_id}")
19525
+ _safe_print(f" 🔗 Executing AggregateNode: {aggregate_id}")
19421
19526
 
19422
19527
  start_time = datetime.utcnow()
19423
19528
  await self.context.notify_node_state(
@@ -19884,24 +19989,24 @@ class WorkflowJob:
19884
19989
  Follows topological order to ensure proper data flow.
19885
19990
  """
19886
19991
  from datetime import datetime
19887
- print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
19992
+ _safe_print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
19888
19993
 
19889
19994
  # trigger_nodes가 비어있으면 source_node_id에서 연결된 하위 노드 찾기
19890
19995
  trigger_nodes = event.trigger_nodes
19891
19996
  if not trigger_nodes and event.source_node_id:
19892
19997
  trigger_nodes = self._find_trigger_nodes(event.source_node_id)
19893
- print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19998
+ _safe_print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19894
19999
 
19895
20000
  if not trigger_nodes:
19896
- print(f" ⚠️ 트리거할 노드 없음, 스킵")
20001
+ _safe_print(f" ⚠️ 트리거할 노드 없음, 스킵")
19897
20002
  return
19898
20003
 
19899
20004
  logger.debug(f"Realtime update from {event.source_node_id}, triggering: {trigger_nodes}")
19900
- print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
20005
+ _safe_print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
19901
20006
 
19902
20007
  # 🆕 트리거된 노드들 + 하위 노드들을 모두 찾아서 실행 순서대로 정렬
19903
20008
  nodes_to_execute = self._find_downstream_nodes(trigger_nodes)
19904
- print(f" → 실행할 노드 체인: {nodes_to_execute}")
20009
+ _safe_print(f" → 실행할 노드 체인: {nodes_to_execute}")
19905
20010
 
19906
20011
  # Split→Aggregate 분기는 노드를 하나씩 재실행해서는 절대 채워지지 않는다.
19907
20012
  # AggregateNode 의 유일한 입력인 node_state["_collected_items"] 를 쓰는 곳은
@@ -19918,7 +20023,7 @@ class WorkflowJob:
19918
20023
  split_node = self.workflow.nodes.get(split_id)
19919
20024
  if not split_node:
19920
20025
  continue
19921
- print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
20026
+ _safe_print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
19922
20027
  await self._execute_split_branch(split_id, split_node, split_pairs, branch)
19923
20028
  branch_driven |= branch | {split_id, aggregate_id}
19924
20029
 
@@ -19940,7 +20045,7 @@ class WorkflowJob:
19940
20045
 
19941
20046
  # Re-execute the triggered node
19942
20047
  try:
19943
- print(f" ▶ Re-executing: {node_id} ({node.node_type})")
20048
+ _safe_print(f" ▶ Re-executing: {node_id} ({node.node_type})")
19944
20049
 
19945
20050
  # 소스 노드 ID를 config에 추가하여 최신 데이터 참조 가능하게
19946
20051
  config_with_source = dict(node.config)
@@ -20120,7 +20225,17 @@ class WorkflowJob:
20120
20225
  await self._task
20121
20226
  except asyncio.CancelledError:
20122
20227
  pass
20123
-
20228
+
20229
+ # Cleanup persistent trackers / flow-end nodes / broker·JIF 구독.
20230
+ # 여태 stop() 은 이 정리를 빼먹어(cancel() 에만 있었다) 잡이 stop 으로 끝나면
20231
+ # 계좌 추적기의 주기 폴링 루프가 취소되지 않고 무한히 LS 를 계속 조회했다(레이트
20232
+ # 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
20233
+ # 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
20234
+ await self._cleanup_broker_fill_subscriptions()
20235
+ await self._cleanup_jif_subscriptions()
20236
+ await self.context.cleanup_persistent_nodes()
20237
+ await self.context.cleanup_flow_end_nodes()
20238
+
20124
20239
  # Cleanup RiskTracker flush
20125
20240
  if self.context.risk_tracker:
20126
20241
  try:
@@ -7,8 +7,8 @@ workflows that are still *semantically* wrong or *unsafe* — they validate
7
7
  *executability*, never *intent* or *safety*.
8
8
 
9
9
  The four ground-truth anti-patterns (verified against the canonical example
10
- workflows shipped in ``programgarden/examples/workflows`` and the live node
11
- registry):
10
+ corpus — maintained internally since the 2026-08 privatization — and the live
11
+ node registry):
12
12
 
13
13
  * **R1 — order quantity bound to an AIAgent response.** An AIAgent
14
14
  sentiment/decision output wired straight into an order quantity risks a
@@ -27,6 +27,15 @@ registry):
27
27
  Always trades the same size regardless of balance/risk. (Advisory.)
28
28
  * **R4 — order node carrying ``paper_trading``.** That field is a *broker*
29
29
  field; on an order node it is silently ignored. (Advisory.)
30
+ * **R5 — unknown node config key.** A key that is not in the node's
31
+ ``get_field_schema()`` nor its declared model fields. ``BaseNode`` sets
32
+ ``extra="allow"`` so Pydantic accepts it, and the executor never reads it —
33
+ the workflow *looks* configured but behaves differently (e.g. ScheduleNode
34
+ ``{mode, schedule}`` instead of ``cron`` → silent 5-min fallback;
35
+ PositionSizingNode ``size_type`` instead of ``method``). (Blocking when
36
+ enabled.) Structural, not keyword-based: the allowed set is derived from the
37
+ registry node class, and unknown / community / dynamic types (no resolvable
38
+ class) are skipped rather than false-flagged.
30
39
 
31
40
  Design contract
32
41
  ---------------
@@ -58,6 +67,7 @@ from programgarden_core import (
58
67
  ErrorLocation,
59
68
  ErrorSeverity,
60
69
  build_error,
70
+ suggest_close_match,
61
71
  )
62
72
 
63
73
  # ---------------------------------------------------------------------------
@@ -68,12 +78,14 @@ RULE_ORDER_QTY_FROM_AI = "order_qty_from_ai_response" # R1
68
78
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA = "structured_output_missing_schema" # R2
69
79
  RULE_HARDCODED_ORDER_QTY = "hardcoded_order_quantity" # R3
70
80
  RULE_ORDER_IGNORED_FIELD = "order_ignored_field" # R4
81
+ RULE_UNKNOWN_NODE_FIELD = "unknown_node_field" # R5
71
82
 
72
83
  ALL_RULES: tuple[str, ...] = (
73
84
  RULE_ORDER_QTY_FROM_AI,
74
85
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA,
75
86
  RULE_HARDCODED_ORDER_QTY,
76
87
  RULE_ORDER_IGNORED_FIELD,
88
+ RULE_UNKNOWN_NODE_FIELD,
77
89
  )
78
90
 
79
91
  _RULE_CODE: Dict[str, ErrorCode] = {
@@ -81,18 +93,23 @@ _RULE_CODE: Dict[str, ErrorCode] = {
81
93
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA: ErrorCode.SEMANTIC_STRUCTURED_OUTPUT_NO_SCHEMA,
82
94
  RULE_HARDCODED_ORDER_QTY: ErrorCode.SEMANTIC_HARDCODED_ORDER_QTY,
83
95
  RULE_ORDER_IGNORED_FIELD: ErrorCode.SEMANTIC_ORDER_IGNORED_FIELD,
96
+ RULE_UNKNOWN_NODE_FIELD: ErrorCode.UNKNOWN_NODE_FIELD,
84
97
  }
85
98
 
86
99
  # Everything off — the default when no config is supplied.
87
100
  DEFAULT_SEMANTIC_SEVERITIES: Dict[str, str] = {r: "off" for r in ALL_RULES}
88
101
 
89
- # Chatbot strict preset — reproduces the legacy ``workflow_semantic_lint``
90
- # behavior exactly (R1/R2 block, R3/R4 advise). Callers opt in with this.
102
+ # Chatbot strict preset — the save-chokepoint anti-pattern gate (R1/R2/R5
103
+ # block, R3/R4 advise). R5 blocks hallucinated / typo'd node config keys
104
+ # (e.g. ScheduleNode {mode,schedule} instead of cron, PositionSizingNode
105
+ # size_type instead of method) that ``extra="allow"`` silently accepts — the
106
+ # workflow looks configured but the extra keys are ignored at runtime.
91
107
  STRICT_SEMANTIC_SEVERITIES: Dict[str, str] = {
92
108
  RULE_ORDER_QTY_FROM_AI: "error",
93
109
  RULE_STRUCTURED_OUTPUT_NO_SCHEMA: "error",
94
110
  RULE_HARDCODED_ORDER_QTY: "warning",
95
111
  RULE_ORDER_IGNORED_FIELD: "warning",
112
+ RULE_UNKNOWN_NODE_FIELD: "error",
96
113
  }
97
114
 
98
115
  _SEVERITY_MAP: Dict[str, ErrorSeverity] = {
@@ -171,6 +188,47 @@ def _is_aiagent_node(node_type: Any) -> bool:
171
188
  return False
172
189
 
173
190
 
191
+ # Structural keys present on every node dict but not always declared as a schema
192
+ # / model field. ``config`` / ``description`` are BaseNode fields; ``plugin`` /
193
+ # ``fields`` are the ConditionNode plugin-binding structure carried at node level.
194
+ # ``credential_id`` is the canonical credential-binding key (``nodes[].credential_id``
195
+ # + top-level ``credentials[]``): the executor's universal node-creation layer reads
196
+ # it (``config.get("credential_id")`` → ``_inject_credentials``) for every node type,
197
+ # so no per-node schema declares it — flagging it would block every credential-bound
198
+ # workflow at the strict save gate.
199
+ _RESERVED_NODE_KEYS: frozenset = frozenset(
200
+ {"id", "type", "category", "position", "config", "description", "plugin",
201
+ "fields", "credential_id"}
202
+ )
203
+
204
+
205
+ def _node_allowed_keys(node_type: Any) -> Optional[frozenset]:
206
+ """Allowed top-level config keys for a registered node type, or None.
207
+
208
+ The allowed set is the union of the node's declared Pydantic model fields,
209
+ its ``get_field_schema()`` keys (the AI/UI-facing schema), and the reserved
210
+ structural keys. Returns None when ``node_type`` does not resolve to a
211
+ registered class (unknown / community / dynamic) — R5 skips those, since
212
+ their schema is unknown and any key would otherwise be false-flagged.
213
+ Never raises.
214
+ """
215
+ cls = _node_class(node_type)
216
+ if cls is None:
217
+ return None
218
+ allowed = set(_RESERVED_NODE_KEYS)
219
+ try:
220
+ allowed |= set(cls.model_fields.keys())
221
+ except Exception: # pragma: no cover - defensive
222
+ pass
223
+ try:
224
+ fs = cls.get_field_schema()
225
+ if isinstance(fs, dict):
226
+ allowed |= set(fs.keys())
227
+ except Exception: # pragma: no cover - defensive
228
+ pass
229
+ return frozenset(allowed)
230
+
231
+
174
232
  # ---------------------------------------------------------------------------
175
233
  # Binding helpers
176
234
  # ---------------------------------------------------------------------------
@@ -261,6 +319,7 @@ def analyze_workflow_semantics(
261
319
  sev_r2 = _enabled(severities, RULE_STRUCTURED_OUTPUT_NO_SCHEMA)
262
320
  sev_r3 = _enabled(severities, RULE_HARDCODED_ORDER_QTY)
263
321
  sev_r4 = _enabled(severities, RULE_ORDER_IGNORED_FIELD)
322
+ sev_r5 = _enabled(severities, RULE_UNKNOWN_NODE_FIELD)
264
323
 
265
324
  # Index node id → node, and whether any PositionSizingNode exists (R3).
266
325
  nodes_by_id: Dict[str, Dict[str, Any]] = {}
@@ -282,6 +341,41 @@ def analyze_workflow_semantics(
282
341
  node_type = node.get("type")
283
342
  node_id = node.get("id") if isinstance(node.get("id"), str) else "?"
284
343
 
344
+ # ── R5: unknown node config key (hallucinated / typo'd field) ──────
345
+ # Runs for every node. Structural: the allowed set comes from the
346
+ # registry node class; unknown / dynamic types (allowed is None) are
347
+ # skipped rather than false-flagged.
348
+ if sev_r5 is not None:
349
+ allowed = _node_allowed_keys(node_type)
350
+ if allowed is not None:
351
+ for key in node.keys():
352
+ if key in allowed:
353
+ continue
354
+ errors.append(build_error(
355
+ ErrorCode.UNKNOWN_NODE_FIELD,
356
+ f"Node '{node_id}' ({node_type}) has field '{key}', which is not "
357
+ f"in this node's schema. Pydantic accepts it (extra='allow') but "
358
+ f"the executor never reads it, so the workflow looks configured "
359
+ f"but behaves differently than it appears.",
360
+ severity=sev_r5,
361
+ location=ErrorLocation(
362
+ node_id=node_id,
363
+ node_type=node_type if isinstance(node_type, str) else None,
364
+ field_path=key if isinstance(key, str) else None,
365
+ ),
366
+ available_values=(
367
+ suggest_close_match(key, sorted(allowed))
368
+ if isinstance(key, str) else None
369
+ ),
370
+ suggestion=(
371
+ f"'{key}' 는 {node_type} 노드에 없는 필드예요. 오탈자이거나 "
372
+ f"다른 노드의 필드일 수 있습니다. 이 노드의 config_schema 에 "
373
+ f"정의된 필드만 사용하세요 (예: ScheduleNode 는 cron, "
374
+ f"PositionSizingNode 는 method)."
375
+ ),
376
+ details={"rule": RULE_UNKNOWN_NODE_FIELD, "field": key},
377
+ ))
378
+
285
379
  # ── R2: AIAgent structured output with no schema (and no preset) ──
286
380
  if sev_r2 is not None and _is_aiagent_node(node_type):
287
381
  if node.get("output_format") == "structured":
@@ -396,6 +490,7 @@ __all__ = [
396
490
  "RULE_STRUCTURED_OUTPUT_NO_SCHEMA",
397
491
  "RULE_HARDCODED_ORDER_QTY",
398
492
  "RULE_ORDER_IGNORED_FIELD",
493
+ "RULE_UNKNOWN_NODE_FIELD",
399
494
  "ALL_RULES",
400
495
  "DEFAULT_SEMANTIC_SEVERITIES",
401
496
  "STRICT_SEMANTIC_SEVERITIES",
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.29.3"
8
+ version = "1.30.0"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -29,7 +29,7 @@ lxml = "^6.0.2"
29
29
  pytickersymbols = {version = ">=1.17.5", python = ">=3.12,<4.0"}
30
30
  aiosqlite = "^0.20.0"
31
31
  litellm = ">=1.40.0"
32
- programgarden-core = "^1.21.0"
32
+ programgarden-core = "^1.22.0"
33
33
  programgarden-finance = "^1.6.14"
34
34
  programgarden-community = "^1.13.11"
35
35
 
File without changes