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.
- {programgarden-1.29.3 → programgarden-1.30.0}/PKG-INFO +2 -2
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/executor.py +207 -92
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/semantic_rules.py +99 -4
- {programgarden-1.29.3 → programgarden-1.30.0}/pyproject.toml +2 -2
- {programgarden-1.29.3 → programgarden-1.30.0}/README.md +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/client.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/code_worker.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/context.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resolver.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.29.3 → programgarden-1.30.0}/programgarden/tools/sqlite_tools.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8400
|
-
|
|
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
|
-
|
|
8481
|
+
_safe_print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
|
|
8415
8482
|
except Exception as e:
|
|
8416
|
-
|
|
8417
|
-
|
|
8418
|
-
|
|
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
|
-
|
|
8430
|
-
|
|
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
|
-
|
|
8445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8467
|
-
|
|
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
|
-
|
|
8539
|
+
_safe_print(values)
|
|
8473
8540
|
else:
|
|
8474
8541
|
# flat dict
|
|
8475
8542
|
for k, v in list(data.items())[:limit]:
|
|
8476
|
-
|
|
8543
|
+
_safe_print(f" {k:<25}: {self._format_value(v)}")
|
|
8477
8544
|
|
|
8478
|
-
|
|
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
|
-
|
|
8493
|
-
|
|
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
|
-
|
|
8499
|
-
|
|
8500
|
-
|
|
8501
|
-
|
|
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
|
-
|
|
8507
|
-
|
|
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
|
-
|
|
8580
|
+
_safe_print(f"\n🎯 시그널 ({len(signals)}개):")
|
|
8514
8581
|
for sig in signals[:10]: # 최대 10개만 표시
|
|
8515
|
-
|
|
8582
|
+
_safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
|
|
8516
8583
|
if len(signals) > 10:
|
|
8517
|
-
|
|
8584
|
+
_safe_print(f" ... 외 {len(signals) - 10}개")
|
|
8518
8585
|
else:
|
|
8519
|
-
|
|
8586
|
+
_safe_print("(데이터 없음)")
|
|
8520
8587
|
|
|
8521
|
-
|
|
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
|
-
|
|
8546
|
-
|
|
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
|
-
|
|
8572
|
-
|
|
8573
|
-
|
|
8574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8656
|
+
_safe_print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
|
|
8590
8657
|
if len(signals) > 15:
|
|
8591
|
-
|
|
8658
|
+
_safe_print(f" ... 외 {len(signals) - 15}개")
|
|
8592
8659
|
|
|
8593
|
-
|
|
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
|
-
|
|
8620
|
-
|
|
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
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8710
|
+
_safe_print(f"\n🎯 시그널 ({len(signals)}개):")
|
|
8644
8711
|
for sig in signals[:10]: # 최대 10개만 표시
|
|
8645
|
-
|
|
8712
|
+
_safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
|
|
8646
8713
|
if len(signals) > 10:
|
|
8647
|
-
|
|
8714
|
+
_safe_print(f" ... 외 {len(signals) - 10}개")
|
|
8648
8715
|
else:
|
|
8649
|
-
|
|
8716
|
+
_safe_print("(데이터 없음)")
|
|
8650
8717
|
|
|
8651
|
-
|
|
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
|
-
|
|
8675
|
-
|
|
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
|
-
|
|
8679
|
-
|
|
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
|
-
|
|
8750
|
+
_safe_print(f"{str(x_val):<20} | {self._format_value(y_val)}")
|
|
8684
8751
|
elif isinstance(data, dict):
|
|
8685
|
-
|
|
8686
|
-
|
|
8752
|
+
_safe_print(f"{'Key':<20} | {'Value'}")
|
|
8753
|
+
_safe_print("-" * 60)
|
|
8687
8754
|
for k, v in list(data.items())[:20]:
|
|
8688
|
-
|
|
8755
|
+
_safe_print(f"{str(k):<20} | {self._format_value(v)}")
|
|
8689
8756
|
|
|
8690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19998
|
+
_safe_print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
|
|
19894
19999
|
|
|
19895
20000
|
if not trigger_nodes:
|
|
19896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
90
|
-
#
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.29.3 → programgarden-1.30.0}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|