programgarden 1.29.2__tar.gz → 1.29.4__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.2 → programgarden-1.29.4}/PKG-INFO +1 -1
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/__init__.py +1 -1
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/executor.py +576 -93
- {programgarden-1.29.2 → programgarden-1.29.4}/pyproject.toml +1 -1
- {programgarden-1.29.2 → programgarden-1.29.4}/README.md +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/binding_validator.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/client.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/code_worker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/context.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/deep_fixtures.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/node_runner.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resolver.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/context.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/semantic_rules.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/validation_recommender.py +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;
|
|
@@ -77,6 +116,95 @@ REALTIME_NODE_TYPES: frozenset = frozenset({
|
|
|
77
116
|
})
|
|
78
117
|
|
|
79
118
|
|
|
119
|
+
def _order_failure_from_outputs(outputs: Any) -> Optional[str]:
|
|
120
|
+
"""Return a human-readable reason if an order node *failed without raising*.
|
|
121
|
+
|
|
122
|
+
Order nodes never raise on failure — they return ``order_result.success=False``
|
|
123
|
+
with an :class:`EmptyOrderReason`. A node that returns normally is reported as
|
|
124
|
+
COMPLETED with ``errors_count=0`` and ``last_error=None``, so "the order never
|
|
125
|
+
went out" leaves **no trace on any observable surface**: the dashboard, the
|
|
126
|
+
monitoring page and the user all see a clean run. In automated trading that is
|
|
127
|
+
the single most dangerous failure mode.
|
|
128
|
+
|
|
129
|
+
``no_signal`` ("no trading signal today") is a legitimate no-op and is NOT a
|
|
130
|
+
failure. Every other reason (``fetch_failed``, ``no_symbol``, or an explicit
|
|
131
|
+
``error`` with no reason at all) means an order was supposed to go out and
|
|
132
|
+
didn't — surface it.
|
|
133
|
+
|
|
134
|
+
Returns None when there is nothing wrong.
|
|
135
|
+
"""
|
|
136
|
+
if not isinstance(outputs, dict):
|
|
137
|
+
return None
|
|
138
|
+
result = outputs.get("order_result")
|
|
139
|
+
if not isinstance(result, dict) or result.get("success") is not False:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
reason = result.get("reason")
|
|
143
|
+
if reason == EmptyOrderReason.NO_SIGNAL.value:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
parts = [
|
|
147
|
+
str(p) for p in (result.get("message"), result.get("detail"), result.get("error"))
|
|
148
|
+
if p
|
|
149
|
+
]
|
|
150
|
+
label = reason or "order_failed"
|
|
151
|
+
return f"{label}: {' — '.join(parts)}" if parts else str(label)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _orders_placed_from_outputs(outputs: Any) -> int:
|
|
155
|
+
"""Return how many orders this node successfully *submitted* (accepted).
|
|
156
|
+
|
|
157
|
+
DEF-26: the job's ``orders_placed`` stat was initialised to 0 and never
|
|
158
|
+
incremented, so the dashboard/monitoring always reported "0 orders placed"
|
|
159
|
+
even after a real fill. This derives the submitted count from the node's
|
|
160
|
+
``order_result``:
|
|
161
|
+
|
|
162
|
+
- single-order shape (``_order_result``): 1 when ``success is True`` (an
|
|
163
|
+
order number came back = accepted), else 0.
|
|
164
|
+
- batch shape (``success_count``): that count (defensive — currently unused
|
|
165
|
+
by the live single-order path).
|
|
166
|
+
- non-order nodes / failed orders / no_signal: 0.
|
|
167
|
+
|
|
168
|
+
"Submitted" means accepted by the broker (order number returned), NOT
|
|
169
|
+
filled — see :func:`_orders_filled_from_outputs` for fills (DEF-27).
|
|
170
|
+
"""
|
|
171
|
+
if not isinstance(outputs, dict):
|
|
172
|
+
return 0
|
|
173
|
+
result = outputs.get("order_result")
|
|
174
|
+
if not isinstance(result, dict):
|
|
175
|
+
return 0
|
|
176
|
+
if "success_count" in result:
|
|
177
|
+
try:
|
|
178
|
+
return max(0, int(result.get("success_count") or 0))
|
|
179
|
+
except (TypeError, ValueError):
|
|
180
|
+
return 0
|
|
181
|
+
return 1 if result.get("success") is True else 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _orders_filled_from_outputs(outputs: Any) -> int:
|
|
185
|
+
"""Return how many orders this node confirmed as *fully filled*.
|
|
186
|
+
|
|
187
|
+
DEF-26/DEF-27: ``orders_filled`` was a dead counter, and the engine
|
|
188
|
+
reported a bare "submitted" (order accepted) as if the order had traded.
|
|
189
|
+
An order only counts here when a post-submission re-query confirmed a
|
|
190
|
+
complete fill (``order_result.status == "filled"``, set by
|
|
191
|
+
:meth:`NewOrderNodeExecutor._confirm_order_fill`). ``submitted`` / ``open``
|
|
192
|
+
/ ``partially_filled`` deliberately do NOT count — an accepted-but-unfilled
|
|
193
|
+
order must never inflate the fill counter.
|
|
194
|
+
"""
|
|
195
|
+
if not isinstance(outputs, dict):
|
|
196
|
+
return 0
|
|
197
|
+
result = outputs.get("order_result")
|
|
198
|
+
if not isinstance(result, dict):
|
|
199
|
+
return 0
|
|
200
|
+
if "filled_count" in result:
|
|
201
|
+
try:
|
|
202
|
+
return max(0, int(result.get("filled_count") or 0))
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
return 0
|
|
205
|
+
return 1 if (result.get("status") == "filled" and result.get("success") is True) else 0
|
|
206
|
+
|
|
207
|
+
|
|
80
208
|
def _free_root_names(text: str) -> Set[str]:
|
|
81
209
|
"""Return the set of free-variable root identifiers referenced (ctx=Load) by
|
|
82
210
|
every ``{{ ... }}`` expression embedded in ``text``.
|
|
@@ -8307,8 +8435,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8307
8435
|
|
|
8308
8436
|
if chart_type == "summary":
|
|
8309
8437
|
# Raw JSON 표시
|
|
8310
|
-
|
|
8311
|
-
|
|
8438
|
+
_safe_print(f"\n📋 {title or 'Data Summary'} [{now}]")
|
|
8439
|
+
_safe_print("=" * 60)
|
|
8312
8440
|
try:
|
|
8313
8441
|
def serialize(obj):
|
|
8314
8442
|
if hasattr(obj, 'model_dump'):
|
|
@@ -8322,11 +8450,11 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8322
8450
|
return obj
|
|
8323
8451
|
|
|
8324
8452
|
serialized = serialize(data)
|
|
8325
|
-
|
|
8453
|
+
_safe_print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
|
|
8326
8454
|
except Exception as e:
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8455
|
+
_safe_print(f"(직렬화 실패: {e})")
|
|
8456
|
+
_safe_print(str(data)[:500])
|
|
8457
|
+
_safe_print("=" * 60 + "\n")
|
|
8330
8458
|
|
|
8331
8459
|
output_data["data"] = data
|
|
8332
8460
|
|
|
@@ -8337,8 +8465,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8337
8465
|
sort_by = config.get("sort_by")
|
|
8338
8466
|
sort_order = config.get("sort_order", "desc")
|
|
8339
8467
|
|
|
8340
|
-
|
|
8341
|
-
|
|
8468
|
+
_safe_print(f"\n📊 {title or 'Table'} [{now}]")
|
|
8469
|
+
_safe_print("=" * 80)
|
|
8342
8470
|
|
|
8343
8471
|
# 데이터 형식 판단
|
|
8344
8472
|
if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict):
|
|
@@ -8352,13 +8480,13 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8352
8480
|
(k, k) for k in list(rows[0].keys())[:8]
|
|
8353
8481
|
]
|
|
8354
8482
|
header = " | ".join(f"{label:<12}" for _, label in cols)
|
|
8355
|
-
|
|
8356
|
-
|
|
8483
|
+
_safe_print(header)
|
|
8484
|
+
_safe_print("-" * 80)
|
|
8357
8485
|
for row in rows:
|
|
8358
8486
|
values = " | ".join(
|
|
8359
8487
|
f"{self._format_value(row.get(key)):<12}" for key, _ in cols
|
|
8360
8488
|
)
|
|
8361
|
-
|
|
8489
|
+
_safe_print(values)
|
|
8362
8490
|
|
|
8363
8491
|
elif isinstance(data, dict):
|
|
8364
8492
|
# dict of dicts (symbol: {field: value}) 또는 flat dict
|
|
@@ -8374,19 +8502,19 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8374
8502
|
(k, k) for k in list(items[0][1].keys())[:6]
|
|
8375
8503
|
]
|
|
8376
8504
|
header = f"{'Key':<12} | " + " | ".join(f"{label:<12}" for _, label in cols)
|
|
8377
|
-
|
|
8378
|
-
|
|
8505
|
+
_safe_print(header)
|
|
8506
|
+
_safe_print("-" * 80)
|
|
8379
8507
|
for key, val in items:
|
|
8380
8508
|
values = f"{key:<12} | " + " | ".join(
|
|
8381
8509
|
f"{self._format_value(val.get(col_key)):<12}" for col_key, _ in cols
|
|
8382
8510
|
)
|
|
8383
|
-
|
|
8511
|
+
_safe_print(values)
|
|
8384
8512
|
else:
|
|
8385
8513
|
# flat dict
|
|
8386
8514
|
for k, v in list(data.items())[:limit]:
|
|
8387
|
-
|
|
8515
|
+
_safe_print(f" {k:<25}: {self._format_value(v)}")
|
|
8388
8516
|
|
|
8389
|
-
|
|
8517
|
+
_safe_print("=" * 80 + "\n")
|
|
8390
8518
|
output_data["data"] = data
|
|
8391
8519
|
|
|
8392
8520
|
elif chart_type == "line":
|
|
@@ -8400,36 +8528,36 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8400
8528
|
context.log("error", f"line 차트에 x_field, y_field 필수", node_id)
|
|
8401
8529
|
return {"rendered": False, "error": "x_field, y_field 필수"}
|
|
8402
8530
|
|
|
8403
|
-
|
|
8404
|
-
|
|
8531
|
+
_safe_print(f"\n📈 {title or 'Line Chart'} [{now}]")
|
|
8532
|
+
_safe_print("=" * 60)
|
|
8405
8533
|
|
|
8406
8534
|
if isinstance(data, list) and len(data) > 0:
|
|
8407
8535
|
first = data[0]
|
|
8408
8536
|
last = data[-1]
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8537
|
+
_safe_print(f"📊 데이터 포인트: {len(data)}개")
|
|
8538
|
+
_safe_print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
|
|
8539
|
+
_safe_print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
|
|
8540
|
+
_safe_print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
|
|
8413
8541
|
|
|
8414
8542
|
# 최대/최소
|
|
8415
8543
|
values = [d.get(y_field, 0) for d in data if d.get(y_field) is not None]
|
|
8416
8544
|
if values:
|
|
8417
|
-
|
|
8418
|
-
|
|
8545
|
+
_safe_print(f"📈 최대: {self._format_value(max(values))}")
|
|
8546
|
+
_safe_print(f"📉 최소: {self._format_value(min(values))}")
|
|
8419
8547
|
|
|
8420
8548
|
# 시그널 마커 표시
|
|
8421
8549
|
if signal_field:
|
|
8422
8550
|
signals = self._extract_signals(data, x_field, signal_field, side_field)
|
|
8423
8551
|
if signals:
|
|
8424
|
-
|
|
8552
|
+
_safe_print(f"\n🎯 시그널 ({len(signals)}개):")
|
|
8425
8553
|
for sig in signals[:10]: # 최대 10개만 표시
|
|
8426
|
-
|
|
8554
|
+
_safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
|
|
8427
8555
|
if len(signals) > 10:
|
|
8428
|
-
|
|
8556
|
+
_safe_print(f" ... 외 {len(signals) - 10}개")
|
|
8429
8557
|
else:
|
|
8430
|
-
|
|
8558
|
+
_safe_print("(데이터 없음)")
|
|
8431
8559
|
|
|
8432
|
-
|
|
8560
|
+
_safe_print("=" * 60 + "\n")
|
|
8433
8561
|
output_data["data"] = data
|
|
8434
8562
|
output_data["x_field"] = x_field
|
|
8435
8563
|
output_data["y_field"] = y_field
|
|
@@ -8453,8 +8581,8 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8453
8581
|
context.log("error", f"multi_line 차트에 x_field, y_field, series_key 필수", node_id)
|
|
8454
8582
|
return {"rendered": False, "error": "x_field, y_field, series_key 필수"}
|
|
8455
8583
|
|
|
8456
|
-
|
|
8457
|
-
|
|
8584
|
+
_safe_print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
|
|
8585
|
+
_safe_print("=" * 80)
|
|
8458
8586
|
|
|
8459
8587
|
# data가 평탄화된 배열 형식인 경우 [{symbol, date, value, ...}, ...]
|
|
8460
8588
|
if isinstance(data, list):
|
|
@@ -8479,29 +8607,29 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8479
8607
|
|
|
8480
8608
|
series_items = series_items[:limit]
|
|
8481
8609
|
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
|
|
8610
|
+
_safe_print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
|
|
8611
|
+
_safe_print("-" * 80)
|
|
8612
|
+
_safe_print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
|
|
8613
|
+
_safe_print("-" * 80)
|
|
8486
8614
|
|
|
8487
8615
|
for symbol, rows in series_items:
|
|
8488
8616
|
if isinstance(rows, list) and len(rows) > 0:
|
|
8489
8617
|
first_val = rows[0].get(y_field, 0)
|
|
8490
8618
|
last_val = rows[-1].get(y_field, 0)
|
|
8491
|
-
|
|
8619
|
+
_safe_print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
|
|
8492
8620
|
|
|
8493
8621
|
# 시그널 마커 표시
|
|
8494
8622
|
if signal_field:
|
|
8495
8623
|
signals = self._extract_signals(data, x_field, signal_field, side_field, series_key)
|
|
8496
8624
|
if signals:
|
|
8497
|
-
|
|
8625
|
+
_safe_print(f"\n🎯 시그널 ({len(signals)}개):")
|
|
8498
8626
|
for sig in signals[:15]: # 최대 15개만 표시
|
|
8499
8627
|
series_info = f" [{sig.get('series', '')}]" if sig.get('series') else ""
|
|
8500
|
-
|
|
8628
|
+
_safe_print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
|
|
8501
8629
|
if len(signals) > 15:
|
|
8502
|
-
|
|
8630
|
+
_safe_print(f" ... 외 {len(signals) - 15}개")
|
|
8503
8631
|
|
|
8504
|
-
|
|
8632
|
+
_safe_print("=" * 80 + "\n")
|
|
8505
8633
|
output_data["data"] = data
|
|
8506
8634
|
output_data["x_field"] = x_field
|
|
8507
8635
|
output_data["y_field"] = y_field
|
|
@@ -8527,16 +8655,16 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8527
8655
|
context.log("error", f"candlestick 차트에 date_field, open_field, high_field, low_field, close_field 필수", node_id)
|
|
8528
8656
|
return {"rendered": False, "error": "OHLC 필드 필수"}
|
|
8529
8657
|
|
|
8530
|
-
|
|
8531
|
-
|
|
8658
|
+
_safe_print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
|
|
8659
|
+
_safe_print("=" * 80)
|
|
8532
8660
|
|
|
8533
8661
|
if isinstance(data, list) and len(data) > 0:
|
|
8534
8662
|
first = data[0]
|
|
8535
8663
|
last = data[-1]
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8664
|
+
_safe_print(f"📊 캔들 수: {len(data)}개")
|
|
8665
|
+
_safe_print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
|
|
8666
|
+
_safe_print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
|
|
8667
|
+
_safe_print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
|
|
8540
8668
|
|
|
8541
8669
|
# 수익률
|
|
8542
8670
|
start_price = first.get(close_field, 0)
|
|
@@ -8545,21 +8673,21 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8545
8673
|
pct_change = (end_price - start_price) / start_price * 100
|
|
8546
8674
|
sign = "+" if pct_change >= 0 else ""
|
|
8547
8675
|
color = "\033[92m" if pct_change >= 0 else "\033[91m"
|
|
8548
|
-
|
|
8676
|
+
_safe_print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
|
|
8549
8677
|
|
|
8550
8678
|
# 시그널 마커 표시
|
|
8551
8679
|
if signal_field:
|
|
8552
8680
|
signals = self._extract_signals(data, date_field, signal_field, side_field)
|
|
8553
8681
|
if signals:
|
|
8554
|
-
|
|
8682
|
+
_safe_print(f"\n🎯 시그널 ({len(signals)}개):")
|
|
8555
8683
|
for sig in signals[:10]: # 최대 10개만 표시
|
|
8556
|
-
|
|
8684
|
+
_safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
|
|
8557
8685
|
if len(signals) > 10:
|
|
8558
|
-
|
|
8686
|
+
_safe_print(f" ... 외 {len(signals) - 10}개")
|
|
8559
8687
|
else:
|
|
8560
|
-
|
|
8688
|
+
_safe_print("(데이터 없음)")
|
|
8561
8689
|
|
|
8562
|
-
|
|
8690
|
+
_safe_print("=" * 80 + "\n")
|
|
8563
8691
|
output_data["data"] = data
|
|
8564
8692
|
output_data["date_field"] = date_field
|
|
8565
8693
|
output_data["open_field"] = open_field
|
|
@@ -8582,23 +8710,23 @@ class DisplayNodeExecutor(NodeExecutorBase):
|
|
|
8582
8710
|
context.log("error", f"bar 차트에 x_field, y_field 필수", node_id)
|
|
8583
8711
|
return {"rendered": False, "error": "x_field, y_field 필수"}
|
|
8584
8712
|
|
|
8585
|
-
|
|
8586
|
-
|
|
8713
|
+
_safe_print(f"\n📊 {title or 'Bar Chart'} [{now}]")
|
|
8714
|
+
_safe_print("=" * 60)
|
|
8587
8715
|
|
|
8588
8716
|
if isinstance(data, list) and len(data) > 0:
|
|
8589
|
-
|
|
8590
|
-
|
|
8717
|
+
_safe_print(f"{'Bar':<20} | {y_field}")
|
|
8718
|
+
_safe_print("-" * 60)
|
|
8591
8719
|
for item in data[:20]:
|
|
8592
8720
|
x_val = item.get(x_field, "")
|
|
8593
8721
|
y_val = item.get(y_field, 0)
|
|
8594
|
-
|
|
8722
|
+
_safe_print(f"{str(x_val):<20} | {self._format_value(y_val)}")
|
|
8595
8723
|
elif isinstance(data, dict):
|
|
8596
|
-
|
|
8597
|
-
|
|
8724
|
+
_safe_print(f"{'Key':<20} | {'Value'}")
|
|
8725
|
+
_safe_print("-" * 60)
|
|
8598
8726
|
for k, v in list(data.items())[:20]:
|
|
8599
|
-
|
|
8727
|
+
_safe_print(f"{str(k):<20} | {self._format_value(v)}")
|
|
8600
8728
|
|
|
8601
|
-
|
|
8729
|
+
_safe_print("=" * 60 + "\n")
|
|
8602
8730
|
output_data["data"] = data
|
|
8603
8731
|
output_data["x_field"] = x_field
|
|
8604
8732
|
output_data["y_field"] = y_field
|
|
@@ -13527,6 +13655,20 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
13527
13655
|
context.log("error", f"{node_type}: Unsupported product: {product}", node_id)
|
|
13528
13656
|
return self._error_result(f"Unsupported product: {product}")
|
|
13529
13657
|
|
|
13658
|
+
# === 5.4. DEF-27: 접수(order number) ≠ 체결. 주문 TR 응답은 주문번호만
|
|
13659
|
+
# 돌려주므로, 방금 접수된 주문의 체결 여부를 best-effort 로 재조회해
|
|
13660
|
+
# order_result.status 를 submitted → filled / partially_filled / open
|
|
13661
|
+
# 으로 승격하고 filled_quantity / filled_count 를 붙인다. 순수 관측용 —
|
|
13662
|
+
# 재조회가 실패해도 접수 성공(success=True)은 절대 뒤집지 않는다. ===
|
|
13663
|
+
if (
|
|
13664
|
+
isinstance(order_result, dict)
|
|
13665
|
+
and isinstance(order_result.get("order_result"), dict)
|
|
13666
|
+
and order_result["order_result"].get("success") is True
|
|
13667
|
+
):
|
|
13668
|
+
await self._confirm_order_fill(
|
|
13669
|
+
ls, product, order_type, order_result, config, context, node_id
|
|
13670
|
+
)
|
|
13671
|
+
|
|
13530
13672
|
# === 5.5. A-4: 성공 주문 idempotency 레지스트리에 기록 ===
|
|
13531
13673
|
context.record_order_submitted(
|
|
13532
13674
|
node_id=node_id,
|
|
@@ -14050,6 +14192,259 @@ class NewOrderNodeExecutor(NodeExecutorBase):
|
|
|
14050
14192
|
except Exception as e:
|
|
14051
14193
|
context.log("warning", f"Failed to sync fills after market order: {e}", node_id)
|
|
14052
14194
|
|
|
14195
|
+
async def _confirm_order_fill(
|
|
14196
|
+
self,
|
|
14197
|
+
ls,
|
|
14198
|
+
product: str,
|
|
14199
|
+
order_type: str,
|
|
14200
|
+
order_result: Dict[str, Any],
|
|
14201
|
+
config: Dict[str, Any],
|
|
14202
|
+
context: ExecutionContext,
|
|
14203
|
+
node_id: str,
|
|
14204
|
+
) -> None:
|
|
14205
|
+
"""Best-effort post-submission fill confirmation (DEF-27).
|
|
14206
|
+
|
|
14207
|
+
A broker order TR returns the order number only (= accepted), never a
|
|
14208
|
+
fill. This re-queries the order right after submission and upgrades
|
|
14209
|
+
``order_result['order_result']`` in place:
|
|
14210
|
+
|
|
14211
|
+
- ``status``: submitted → ``filled`` / ``partially_filled`` / ``open``
|
|
14212
|
+
- adds ``filled_quantity`` / ``fill_price`` / ``filled_count``
|
|
14213
|
+
|
|
14214
|
+
Purely additive observability: it never raises and never flips
|
|
14215
|
+
``success``. If the re-query errors or the order is not found, the
|
|
14216
|
+
order stays reported as accepted with ``status="open"`` (honest
|
|
14217
|
+
"accepted, not confirmed filled").
|
|
14218
|
+
|
|
14219
|
+
Fills are recorded a few seconds after the accept ack, so every order is
|
|
14220
|
+
polled over a short bounded window (fill_confirm_attempts ×
|
|
14221
|
+
fill_confirm_delay_seconds), breaking as soon as the fill lands; an order
|
|
14222
|
+
still unfilled at the end is reported "open". Per-product re-query TR:
|
|
14223
|
+
korea_stock=t0425, overseas_stock=COSAQ00102, overseas_futures/option=CIDBQ02400.
|
|
14224
|
+
"""
|
|
14225
|
+
try:
|
|
14226
|
+
inner = order_result.get("order_result", {})
|
|
14227
|
+
order_id = str(order_result.get("order_id") or inner.get("order_id") or "")
|
|
14228
|
+
if not order_id:
|
|
14229
|
+
return
|
|
14230
|
+
if product not in (
|
|
14231
|
+
"korea_stock", "overseas_stock", "overseas_futures", "overseas_futureoption",
|
|
14232
|
+
):
|
|
14233
|
+
return # unknown product: no fill re-query — leave as submitted
|
|
14234
|
+
|
|
14235
|
+
try:
|
|
14236
|
+
ordered_qty = int(inner.get("quantity") or 0)
|
|
14237
|
+
except (TypeError, ValueError):
|
|
14238
|
+
ordered_qty = 0
|
|
14239
|
+
|
|
14240
|
+
# A fill is recorded to the day's order/fill query a few seconds AFTER
|
|
14241
|
+
# the accept ack — even for a marketable order (live-observed on HKEX
|
|
14242
|
+
# futures paper: an immediate re-query still shows unfilled). So poll a
|
|
14243
|
+
# short bounded window for every order type, breaking as soon as the
|
|
14244
|
+
# fill lands. A genuinely resting limit order simply exhausts the window
|
|
14245
|
+
# and is reported "open" (honest). Both knobs are configurable; set
|
|
14246
|
+
# fill_confirm_attempts=1 to disable polling for latency-sensitive flows.
|
|
14247
|
+
try:
|
|
14248
|
+
attempts = int(config.get("fill_confirm_attempts", 4))
|
|
14249
|
+
except (TypeError, ValueError):
|
|
14250
|
+
attempts = 4
|
|
14251
|
+
try:
|
|
14252
|
+
delay = float(config.get("fill_confirm_delay_seconds", 2.0))
|
|
14253
|
+
except (TypeError, ValueError):
|
|
14254
|
+
delay = 2.0
|
|
14255
|
+
attempts = max(1, attempts)
|
|
14256
|
+
|
|
14257
|
+
symbol = inner.get("symbol", "")
|
|
14258
|
+
filled_qty, fill_price = 0, 0.0
|
|
14259
|
+
for i in range(attempts):
|
|
14260
|
+
# Query immediately first (catches an already-recorded fill at no
|
|
14261
|
+
# cost), then wait between retries for the lagged fill to land.
|
|
14262
|
+
if i > 0:
|
|
14263
|
+
await asyncio.sleep(delay)
|
|
14264
|
+
if product == "korea_stock":
|
|
14265
|
+
filled_qty, fill_price = await self._query_korea_fill(
|
|
14266
|
+
ls, order_id, symbol, context, node_id
|
|
14267
|
+
)
|
|
14268
|
+
elif product == "overseas_stock":
|
|
14269
|
+
filled_qty, fill_price = await self._query_overseas_stock_fill(
|
|
14270
|
+
ls, order_id, context, node_id
|
|
14271
|
+
)
|
|
14272
|
+
else: # overseas_futures / overseas_futureoption
|
|
14273
|
+
filled_qty, fill_price = await self._query_overseas_futures_fill(
|
|
14274
|
+
ls, order_id, context, node_id
|
|
14275
|
+
)
|
|
14276
|
+
if ordered_qty > 0 and filled_qty >= ordered_qty:
|
|
14277
|
+
break
|
|
14278
|
+
|
|
14279
|
+
if ordered_qty > 0 and filled_qty >= ordered_qty:
|
|
14280
|
+
inner["status"] = "filled"
|
|
14281
|
+
inner["filled_count"] = 1
|
|
14282
|
+
elif filled_qty > 0:
|
|
14283
|
+
inner["status"] = "partially_filled"
|
|
14284
|
+
inner["filled_count"] = 0
|
|
14285
|
+
else:
|
|
14286
|
+
inner["status"] = "open"
|
|
14287
|
+
inner["filled_count"] = 0
|
|
14288
|
+
inner["filled_quantity"] = filled_qty
|
|
14289
|
+
if fill_price > 0:
|
|
14290
|
+
inner["fill_price"] = fill_price
|
|
14291
|
+
|
|
14292
|
+
context.log(
|
|
14293
|
+
"info",
|
|
14294
|
+
f"Fill confirm: order {order_id} → {inner['status']} "
|
|
14295
|
+
f"({filled_qty}/{ordered_qty})",
|
|
14296
|
+
node_id,
|
|
14297
|
+
)
|
|
14298
|
+
except Exception as e:
|
|
14299
|
+
# Best-effort: a re-query failure must not disturb the (successful)
|
|
14300
|
+
# order submission. Leave order_result untouched (status stays
|
|
14301
|
+
# "submitted").
|
|
14302
|
+
context.log("debug", f"Fill confirmation skipped (best-effort): {e}", node_id)
|
|
14303
|
+
|
|
14304
|
+
async def _query_korea_fill(
|
|
14305
|
+
self,
|
|
14306
|
+
ls,
|
|
14307
|
+
order_id: str,
|
|
14308
|
+
symbol: str,
|
|
14309
|
+
context: ExecutionContext,
|
|
14310
|
+
node_id: str,
|
|
14311
|
+
) -> tuple:
|
|
14312
|
+
"""Return ``(filled_qty, avg_fill_price)`` for a korea_stock order via
|
|
14313
|
+
t0425 (당일 체결/미체결 조회). ``(0, 0.0)`` when not found or on error."""
|
|
14314
|
+
try:
|
|
14315
|
+
from programgarden_finance.ls.korea_stock.accno.t0425.blocks import T0425InBlock
|
|
14316
|
+
|
|
14317
|
+
# expcode 는 빈값(전체 종목)으로 둔다 — 주문번호(ordno)가 authoritative
|
|
14318
|
+
# 매칭 키이고, 심볼 포맷 차이('005930' vs 'A005930' 등)로 내 주문을
|
|
14319
|
+
# 놓치는 것을 피한다. symbol 인자는 로깅/시그니처 호환용으로만 유지.
|
|
14320
|
+
_ = symbol
|
|
14321
|
+
response = await ls.korea_stock().accno().t0425(
|
|
14322
|
+
body=T0425InBlock(
|
|
14323
|
+
expcode="", # 전체 종목 (ordno 로 매칭)
|
|
14324
|
+
chegb="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
|
|
14325
|
+
medosu="0", # 0: 전체
|
|
14326
|
+
sortgb="1", # 1: 주문번호 역순(방금 낸 주문이 앞쪽)
|
|
14327
|
+
cts_ordno="",
|
|
14328
|
+
)
|
|
14329
|
+
).req_async()
|
|
14330
|
+
|
|
14331
|
+
if getattr(response, "error_msg", None):
|
|
14332
|
+
context.log("debug", f"t0425 fill query error: {response.error_msg}", node_id)
|
|
14333
|
+
return 0, 0.0
|
|
14334
|
+
|
|
14335
|
+
for item in getattr(response, "block", None) or []:
|
|
14336
|
+
if str(getattr(item, "ordno", "")) == order_id:
|
|
14337
|
+
filled_qty = int(getattr(item, "cheqty", 0) or 0)
|
|
14338
|
+
fill_price = float(getattr(item, "cheprice", 0) or 0)
|
|
14339
|
+
return filled_qty, fill_price
|
|
14340
|
+
return 0, 0.0
|
|
14341
|
+
except Exception as e:
|
|
14342
|
+
context.log("debug", f"t0425 fill query exception: {e}", node_id)
|
|
14343
|
+
return 0, 0.0
|
|
14344
|
+
|
|
14345
|
+
async def _query_overseas_stock_fill(
|
|
14346
|
+
self,
|
|
14347
|
+
ls,
|
|
14348
|
+
order_id: str,
|
|
14349
|
+
context: ExecutionContext,
|
|
14350
|
+
node_id: str,
|
|
14351
|
+
) -> tuple:
|
|
14352
|
+
"""Return ``(filled_qty, avg_fill_price)`` for an overseas_stock order
|
|
14353
|
+
via COSAQ00102 (주문체결내역조회, 체결분만). ``(0, 0.0)`` when not found
|
|
14354
|
+
or on error."""
|
|
14355
|
+
try:
|
|
14356
|
+
from programgarden_finance import COSAQ00102
|
|
14357
|
+
from datetime import datetime as _dt
|
|
14358
|
+
|
|
14359
|
+
today = _dt.now().strftime("%Y%m%d")
|
|
14360
|
+
response = ls.overseas_stock().accno().cosaq00102(
|
|
14361
|
+
body=COSAQ00102.COSAQ00102InBlock1(
|
|
14362
|
+
RecCnt=1,
|
|
14363
|
+
QryTpCode="1", # 계좌별
|
|
14364
|
+
BkseqTpCode="1", # 역순
|
|
14365
|
+
OrdMktCode="00", # 전체 시장
|
|
14366
|
+
BnsTpCode="0", # 전체(매수/매도)
|
|
14367
|
+
IsuNo="", # 전체 종목
|
|
14368
|
+
SrtOrdNo=999999999, # 역순 시작
|
|
14369
|
+
OrdDt=today,
|
|
14370
|
+
ExecYn="1", # 1: 체결만
|
|
14371
|
+
CrcyCode="000", # 전체 통화
|
|
14372
|
+
ThdayBnsAppYn="0",
|
|
14373
|
+
LoanBalHldYn="0",
|
|
14374
|
+
),
|
|
14375
|
+
)
|
|
14376
|
+
result = await response.req_async()
|
|
14377
|
+
if not result or not getattr(result, "block3", None):
|
|
14378
|
+
return 0, 0.0
|
|
14379
|
+
|
|
14380
|
+
filled_qty = 0
|
|
14381
|
+
amount = 0.0
|
|
14382
|
+
for item in result.block3:
|
|
14383
|
+
if str(getattr(item, "OrdNo", 0)) != order_id:
|
|
14384
|
+
continue
|
|
14385
|
+
q = int(getattr(item, "ExecQty", 0) or 0)
|
|
14386
|
+
p = float(
|
|
14387
|
+
getattr(item, "OvrsExecPrc", 0) or getattr(item, "OvrsOrdPrc", 0) or 0
|
|
14388
|
+
)
|
|
14389
|
+
filled_qty += q
|
|
14390
|
+
amount += q * p
|
|
14391
|
+
avg_price = (amount / filled_qty) if filled_qty > 0 else 0.0
|
|
14392
|
+
return filled_qty, avg_price
|
|
14393
|
+
except Exception as e:
|
|
14394
|
+
context.log("debug", f"COSAQ00102 fill query exception: {e}", node_id)
|
|
14395
|
+
return 0, 0.0
|
|
14396
|
+
|
|
14397
|
+
async def _query_overseas_futures_fill(
|
|
14398
|
+
self,
|
|
14399
|
+
ls,
|
|
14400
|
+
order_id: str,
|
|
14401
|
+
context: ExecutionContext,
|
|
14402
|
+
node_id: str,
|
|
14403
|
+
) -> tuple:
|
|
14404
|
+
"""Return ``(filled_qty, avg_fill_price)`` for an overseas_futures/option
|
|
14405
|
+
order via CIDBQ02400 (당일 주문체결내역, 전체). ``(0, 0.0)`` when not
|
|
14406
|
+
found or on error. Also covers HKEX-futures paper accounts."""
|
|
14407
|
+
try:
|
|
14408
|
+
from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import (
|
|
14409
|
+
CIDBQ02400InBlock1,
|
|
14410
|
+
)
|
|
14411
|
+
from datetime import datetime as _dt
|
|
14412
|
+
|
|
14413
|
+
today = _dt.now().strftime("%Y%m%d")
|
|
14414
|
+
response = await ls.overseas_futureoption().accno().CIDBQ02400(
|
|
14415
|
+
body=CIDBQ02400InBlock1(
|
|
14416
|
+
IsuCodeVal="", # 전체 종목
|
|
14417
|
+
QrySrtDt=today,
|
|
14418
|
+
QryEndDt=today,
|
|
14419
|
+
ThdayTpCode="1", # 1: 당일조회
|
|
14420
|
+
OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
|
|
14421
|
+
BnsTpCode="0", # 0: 전체
|
|
14422
|
+
QryTpCode="1", # 1: 역순(방금 낸 주문이 앞쪽)
|
|
14423
|
+
OrdPtnCode="00", # 00: 전체
|
|
14424
|
+
OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
|
|
14425
|
+
)
|
|
14426
|
+
).req_async()
|
|
14427
|
+
|
|
14428
|
+
if getattr(response, "error_msg", None):
|
|
14429
|
+
context.log("debug", f"CIDBQ02400 fill query error: {response.error_msg}", node_id)
|
|
14430
|
+
return 0, 0.0
|
|
14431
|
+
|
|
14432
|
+
# block2 = 주문/체결 상세 (한 주문에 다중 체결이면 행이 여럿 → ExecQty 합산)
|
|
14433
|
+
filled_qty = 0
|
|
14434
|
+
amount = 0.0
|
|
14435
|
+
for item in getattr(response, "block2", None) or []:
|
|
14436
|
+
if str(getattr(item, "OvrsFutsOrdNo", "")) != order_id:
|
|
14437
|
+
continue
|
|
14438
|
+
q = int(getattr(item, "ExecQty", 0) or 0)
|
|
14439
|
+
p = float(getattr(item, "AbrdFutsExecPrc", 0) or 0)
|
|
14440
|
+
filled_qty += q
|
|
14441
|
+
amount += q * p
|
|
14442
|
+
avg_price = (amount / filled_qty) if filled_qty > 0 else 0.0
|
|
14443
|
+
return filled_qty, avg_price
|
|
14444
|
+
except Exception as e:
|
|
14445
|
+
context.log("debug", f"CIDBQ02400 fill query exception: {e}", node_id)
|
|
14446
|
+
return 0, 0.0
|
|
14447
|
+
|
|
14053
14448
|
async def _execute_overseas_futures(
|
|
14054
14449
|
self,
|
|
14055
14450
|
ls,
|
|
@@ -17847,7 +18242,7 @@ class WorkflowJob:
|
|
|
17847
18242
|
except asyncio.CancelledError:
|
|
17848
18243
|
self.status = "cancelled"
|
|
17849
18244
|
logger.info(f"Job {self.job_id} cancelled")
|
|
17850
|
-
|
|
18245
|
+
_safe_print(f"[CANCELLED] Job {self.job_id} cancelled")
|
|
17851
18246
|
await self.context.notify_job_state("cancelled", self.stats)
|
|
17852
18247
|
except Exception as e:
|
|
17853
18248
|
self.status = "failed"
|
|
@@ -17864,9 +18259,17 @@ class WorkflowJob:
|
|
|
17864
18259
|
}
|
|
17865
18260
|
self.context.log("error", error_msg)
|
|
17866
18261
|
logger.exception(f"Job {self.job_id} failed: {e}")
|
|
17867
|
-
|
|
18262
|
+
# ASCII-only marker: this is the failure-reporting path, so it must
|
|
18263
|
+
# survive a console that could not render the text that failed.
|
|
18264
|
+
_safe_print(f"[FAILED] Job {self.job_id} failed: {e}")
|
|
17868
18265
|
import traceback
|
|
17869
|
-
|
|
18266
|
+
try:
|
|
18267
|
+
# print_exc renders the offending source line, which may itself
|
|
18268
|
+
# hold the character that could not be encoded — so it can raise
|
|
18269
|
+
# the very error it is reporting.
|
|
18270
|
+
traceback.print_exc()
|
|
18271
|
+
except Exception:
|
|
18272
|
+
logger.debug("traceback output dropped", exc_info=True)
|
|
17870
18273
|
await self.context.notify_job_state("failed", self.stats)
|
|
17871
18274
|
|
|
17872
18275
|
# WORKFLOW_FAILED notification
|
|
@@ -17940,7 +18343,7 @@ class WorkflowJob:
|
|
|
17940
18343
|
skip_nodes: 복구 모드에서 이미 완료된 노드 ID 집합 (스킵)
|
|
17941
18344
|
"""
|
|
17942
18345
|
skip_nodes = skip_nodes or set()
|
|
17943
|
-
|
|
18346
|
+
_safe_print(f"🔄 Executing main flow: {self.workflow.execution_order}")
|
|
17944
18347
|
|
|
17945
18348
|
# === Item-based execution setup ===
|
|
17946
18349
|
# Find Split/Aggregate pairs and their branch nodes
|
|
@@ -17980,7 +18383,7 @@ class WorkflowJob:
|
|
|
17980
18383
|
|
|
17981
18384
|
# === Checkpoint restore: 이미 완료된 노드 스킵 ===
|
|
17982
18385
|
if node_id in skip_nodes:
|
|
17983
|
-
|
|
18386
|
+
_safe_print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
|
|
17984
18387
|
self._completed_node_ids.add(node_id)
|
|
17985
18388
|
await self.context.notify_node_state(
|
|
17986
18389
|
node_id=node_id,
|
|
@@ -17991,7 +18394,7 @@ class WorkflowJob:
|
|
|
17991
18394
|
|
|
17992
18395
|
# === Item-based execution: Skip branch nodes (handled by SplitNode) ===
|
|
17993
18396
|
if node_id in branch_nodes and node.node_type not in ("SplitNode", "AggregateNode"):
|
|
17994
|
-
|
|
18397
|
+
_safe_print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
|
|
17995
18398
|
continue
|
|
17996
18399
|
|
|
17997
18400
|
# === Item-based execution: Handle SplitNode specially ===
|
|
@@ -18002,12 +18405,12 @@ class WorkflowJob:
|
|
|
18002
18405
|
|
|
18003
18406
|
# === Item-based execution: Skip AggregateNode (already executed by SplitNode) ===
|
|
18004
18407
|
if node.node_type == "AggregateNode" and node_id in split_aggregate_pairs.values():
|
|
18005
|
-
|
|
18408
|
+
_safe_print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
|
|
18006
18409
|
continue
|
|
18007
18410
|
|
|
18008
18411
|
# === IfNode: 비활성 브랜치 스킵 ===
|
|
18009
18412
|
if node_id in if_skipped_nodes:
|
|
18010
|
-
|
|
18413
|
+
_safe_print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
|
|
18011
18414
|
await self.context.notify_node_state(
|
|
18012
18415
|
node_id=node_id,
|
|
18013
18416
|
node_type=node.node_type,
|
|
@@ -18015,7 +18418,7 @@ class WorkflowJob:
|
|
|
18015
18418
|
)
|
|
18016
18419
|
continue
|
|
18017
18420
|
|
|
18018
|
-
|
|
18421
|
+
_safe_print(f" ▶ Executing node: {node_id} ({node.node_type})")
|
|
18019
18422
|
|
|
18020
18423
|
# 🆕 노드 실행 시작 알림
|
|
18021
18424
|
start_time = datetime.utcnow()
|
|
@@ -18186,7 +18589,7 @@ class WorkflowJob:
|
|
|
18186
18589
|
new_skips = self._compute_if_skip_nodes(node_id, taken)
|
|
18187
18590
|
if_skipped_nodes.update(new_skips)
|
|
18188
18591
|
if new_skips:
|
|
18189
|
-
|
|
18592
|
+
_safe_print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
|
|
18190
18593
|
|
|
18191
18594
|
# deep_validate: a node that *returns* a sole-`error` dict (rather
|
|
18192
18595
|
# than raising) would otherwise be stored as a COMPLETED output and
|
|
@@ -18225,6 +18628,53 @@ class WorkflowJob:
|
|
|
18225
18628
|
},
|
|
18226
18629
|
)
|
|
18227
18630
|
|
|
18631
|
+
# An order node that failed did NOT raise — it returned
|
|
18632
|
+
# order_result.success=False. Reporting that as COMPLETED leaves
|
|
18633
|
+
# errors_count=0 / last_error=None, i.e. every observable surface
|
|
18634
|
+
# says the run succeeded while no order actually went out. Surface
|
|
18635
|
+
# it: FAILED node state + job stats + an error log line. no_signal
|
|
18636
|
+
# ("no trading signal today") is a normal no-op and is excluded.
|
|
18637
|
+
order_failure = _order_failure_from_outputs(outputs)
|
|
18638
|
+
|
|
18639
|
+
# DEF-26/DEF-27: wire the previously-dead orders_placed /
|
|
18640
|
+
# orders_filled counters. placed = orders this node actually
|
|
18641
|
+
# submitted (accepted by the broker); filled = orders a
|
|
18642
|
+
# post-submit re-query confirmed as fully traded. Both are 0 for
|
|
18643
|
+
# non-order nodes and for failed orders, so this is safe to run
|
|
18644
|
+
# unconditionally for every node.
|
|
18645
|
+
self.stats["orders_placed"] += _orders_placed_from_outputs(outputs)
|
|
18646
|
+
self.stats["orders_filled"] += _orders_filled_from_outputs(outputs)
|
|
18647
|
+
|
|
18648
|
+
# deep_validate: promote it to a blocking structured error so the
|
|
18649
|
+
# chatbot learns its order wiring is broken at *build* time instead
|
|
18650
|
+
# of shipping a workflow whose order silently never fires.
|
|
18651
|
+
if (
|
|
18652
|
+
order_failure
|
|
18653
|
+
and getattr(self.context, "is_deep_validate", False)
|
|
18654
|
+
and node_id not in self._node_error_infos
|
|
18655
|
+
):
|
|
18656
|
+
from programgarden_core import (
|
|
18657
|
+
ErrorCode,
|
|
18658
|
+
ErrorLocation,
|
|
18659
|
+
build_error,
|
|
18660
|
+
)
|
|
18661
|
+
self._node_error_infos[node_id] = build_error(
|
|
18662
|
+
ErrorCode.DEEP_VALIDATION_NODE_ERROR,
|
|
18663
|
+
f"Node '{node_id}' ({node.node_type}) placed no order: "
|
|
18664
|
+
f"{order_failure}",
|
|
18665
|
+
location=ErrorLocation(node_id=node_id, node_type=node.node_type),
|
|
18666
|
+
suggestion=(
|
|
18667
|
+
"주문 노드가 주문을 내지 못했습니다. 상류 노드의 출력이 이 노드의 "
|
|
18668
|
+
"주문 입력(order/orders)에 실제로 연결되어 있는지, 표현식이 "
|
|
18669
|
+
"해소되는지, 수량·종목이 채워지는지 확인하세요."
|
|
18670
|
+
),
|
|
18671
|
+
details={
|
|
18672
|
+
"raw_message": order_failure,
|
|
18673
|
+
"deep_validate": True,
|
|
18674
|
+
"stage": "order_not_placed",
|
|
18675
|
+
},
|
|
18676
|
+
)
|
|
18677
|
+
|
|
18228
18678
|
# Store outputs
|
|
18229
18679
|
for out_port_name, value in outputs.items():
|
|
18230
18680
|
self.context.set_output(node_id, out_port_name, value)
|
|
@@ -18233,16 +18683,39 @@ class WorkflowJob:
|
|
|
18233
18683
|
# stay_connected 노드는 RUNNING 상태 유지 (계속 실시간 업데이트)
|
|
18234
18684
|
duration_ms = (datetime.utcnow() - start_time).total_seconds() * 1000
|
|
18235
18685
|
is_stay_connected = node_id in self._stay_connected_nodes
|
|
18236
|
-
|
|
18237
|
-
|
|
18238
|
-
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18686
|
+
|
|
18687
|
+
if order_failure:
|
|
18688
|
+
await self.context.notify_node_state(
|
|
18689
|
+
node_id=node_id,
|
|
18690
|
+
node_type=node.node_type,
|
|
18691
|
+
state=NodeState.FAILED,
|
|
18692
|
+
outputs=outputs,
|
|
18693
|
+
error=order_failure,
|
|
18694
|
+
duration_ms=duration_ms,
|
|
18695
|
+
)
|
|
18696
|
+
self.stats["errors_count"] += 1
|
|
18697
|
+
self.stats["last_error"] = f"{node_id}: {order_failure}"
|
|
18698
|
+
self.stats["last_error_detail"] = {
|
|
18699
|
+
"node_id": node_id,
|
|
18700
|
+
"node_type": node.node_type,
|
|
18701
|
+
"error": order_failure,
|
|
18702
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
18703
|
+
}
|
|
18704
|
+
self.context.log(
|
|
18705
|
+
"error", f"Order not placed by {node_id}: {order_failure}", node_id
|
|
18706
|
+
)
|
|
18707
|
+
else:
|
|
18708
|
+
await self.context.notify_node_state(
|
|
18709
|
+
node_id=node_id,
|
|
18710
|
+
node_type=node.node_type,
|
|
18711
|
+
state=NodeState.RUNNING if is_stay_connected else NodeState.COMPLETED,
|
|
18712
|
+
outputs=outputs,
|
|
18713
|
+
duration_ms=duration_ms,
|
|
18714
|
+
)
|
|
18244
18715
|
|
|
18245
18716
|
# Checkpoint: 노드 완료 추적 + 일회성 워크플로우 노드 완료마다 저장
|
|
18717
|
+
# (실패해도 흐름은 계속한다 — 스케줄 잡이 한 사이클의 주문 실패로
|
|
18718
|
+
# 통째로 죽지 않도록. 관측 표면에는 위에서 이미 실패로 남았다.)
|
|
18246
18719
|
self._completed_node_ids.add(node_id)
|
|
18247
18720
|
if not self._stay_connected_nodes:
|
|
18248
18721
|
await self._save_checkpoint()
|
|
@@ -18417,7 +18890,7 @@ class WorkflowJob:
|
|
|
18417
18890
|
all_results = []
|
|
18418
18891
|
total = len(items)
|
|
18419
18892
|
|
|
18420
|
-
|
|
18893
|
+
_safe_print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
|
|
18421
18894
|
|
|
18422
18895
|
for idx, current_item in enumerate(items):
|
|
18423
18896
|
# === item, index, total을 ExecutionContext에 설정 ===
|
|
@@ -18428,7 +18901,7 @@ class WorkflowJob:
|
|
|
18428
18901
|
|
|
18429
18902
|
# 진행 상황 로그
|
|
18430
18903
|
item_label = current_item.get("symbol", str(current_item)) if isinstance(current_item, dict) else str(current_item)
|
|
18431
|
-
|
|
18904
|
+
_safe_print(f" [{idx+1}/{total}] Processing: {item_label}")
|
|
18432
18905
|
self.context.log("debug", f"Auto-iterate [{idx+1}/{total}]: {item_label}", node_id)
|
|
18433
18906
|
|
|
18434
18907
|
# A-3: per-item spacing for order / external-API nodes
|
|
@@ -18461,7 +18934,7 @@ class WorkflowJob:
|
|
|
18461
18934
|
|
|
18462
18935
|
# 결과 병합: 배열 필드는 병합, 단일 필드는 마지막 값
|
|
18463
18936
|
merged = self._merge_iterate_results(all_results)
|
|
18464
|
-
|
|
18937
|
+
_safe_print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
|
|
18465
18938
|
|
|
18466
18939
|
return merged
|
|
18467
18940
|
|
|
@@ -18655,7 +19128,7 @@ class WorkflowJob:
|
|
|
18655
19128
|
"""
|
|
18656
19129
|
from programgarden_core.bases.listener import NodeState
|
|
18657
19130
|
|
|
18658
|
-
|
|
19131
|
+
_safe_print(f" 🔀 Executing SplitNode branch: {split_id}")
|
|
18659
19132
|
|
|
18660
19133
|
# Get paired AggregateNode
|
|
18661
19134
|
aggregate_id = split_aggregate_pairs.get(split_id)
|
|
@@ -18744,7 +19217,7 @@ class WorkflowJob:
|
|
|
18744
19217
|
results_by_index: Dict[int, Any] = {}
|
|
18745
19218
|
errors: List[str] = []
|
|
18746
19219
|
|
|
18747
|
-
|
|
19220
|
+
_safe_print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
|
|
18748
19221
|
|
|
18749
19222
|
# === Execute branch for each item ===
|
|
18750
19223
|
if parallel:
|
|
@@ -18991,7 +19464,7 @@ class WorkflowJob:
|
|
|
18991
19464
|
if not node:
|
|
18992
19465
|
return
|
|
18993
19466
|
|
|
18994
|
-
|
|
19467
|
+
_safe_print(f" 🔗 Executing AggregateNode: {aggregate_id}")
|
|
18995
19468
|
|
|
18996
19469
|
start_time = datetime.utcnow()
|
|
18997
19470
|
await self.context.notify_node_state(
|
|
@@ -19458,24 +19931,24 @@ class WorkflowJob:
|
|
|
19458
19931
|
Follows topological order to ensure proper data flow.
|
|
19459
19932
|
"""
|
|
19460
19933
|
from datetime import datetime
|
|
19461
|
-
|
|
19934
|
+
_safe_print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
|
|
19462
19935
|
|
|
19463
19936
|
# trigger_nodes가 비어있으면 source_node_id에서 연결된 하위 노드 찾기
|
|
19464
19937
|
trigger_nodes = event.trigger_nodes
|
|
19465
19938
|
if not trigger_nodes and event.source_node_id:
|
|
19466
19939
|
trigger_nodes = self._find_trigger_nodes(event.source_node_id)
|
|
19467
|
-
|
|
19940
|
+
_safe_print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
|
|
19468
19941
|
|
|
19469
19942
|
if not trigger_nodes:
|
|
19470
|
-
|
|
19943
|
+
_safe_print(f" ⚠️ 트리거할 노드 없음, 스킵")
|
|
19471
19944
|
return
|
|
19472
19945
|
|
|
19473
19946
|
logger.debug(f"Realtime update from {event.source_node_id}, triggering: {trigger_nodes}")
|
|
19474
|
-
|
|
19947
|
+
_safe_print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
|
|
19475
19948
|
|
|
19476
19949
|
# 🆕 트리거된 노드들 + 하위 노드들을 모두 찾아서 실행 순서대로 정렬
|
|
19477
19950
|
nodes_to_execute = self._find_downstream_nodes(trigger_nodes)
|
|
19478
|
-
|
|
19951
|
+
_safe_print(f" → 실행할 노드 체인: {nodes_to_execute}")
|
|
19479
19952
|
|
|
19480
19953
|
# Split→Aggregate 분기는 노드를 하나씩 재실행해서는 절대 채워지지 않는다.
|
|
19481
19954
|
# AggregateNode 의 유일한 입력인 node_state["_collected_items"] 를 쓰는 곳은
|
|
@@ -19492,7 +19965,7 @@ class WorkflowJob:
|
|
|
19492
19965
|
split_node = self.workflow.nodes.get(split_id)
|
|
19493
19966
|
if not split_node:
|
|
19494
19967
|
continue
|
|
19495
|
-
|
|
19968
|
+
_safe_print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
|
|
19496
19969
|
await self._execute_split_branch(split_id, split_node, split_pairs, branch)
|
|
19497
19970
|
branch_driven |= branch | {split_id, aggregate_id}
|
|
19498
19971
|
|
|
@@ -19514,7 +19987,7 @@ class WorkflowJob:
|
|
|
19514
19987
|
|
|
19515
19988
|
# Re-execute the triggered node
|
|
19516
19989
|
try:
|
|
19517
|
-
|
|
19990
|
+
_safe_print(f" ▶ Re-executing: {node_id} ({node.node_type})")
|
|
19518
19991
|
|
|
19519
19992
|
# 소스 노드 ID를 config에 추가하여 최신 데이터 참조 가능하게
|
|
19520
19993
|
config_with_source = dict(node.config)
|
|
@@ -19694,7 +20167,17 @@ class WorkflowJob:
|
|
|
19694
20167
|
await self._task
|
|
19695
20168
|
except asyncio.CancelledError:
|
|
19696
20169
|
pass
|
|
19697
|
-
|
|
20170
|
+
|
|
20171
|
+
# Cleanup persistent trackers / flow-end nodes / broker·JIF 구독.
|
|
20172
|
+
# 여태 stop() 은 이 정리를 빼먹어(cancel() 에만 있었다) 잡이 stop 으로 끝나면
|
|
20173
|
+
# 계좌 추적기의 주기 폴링 루프가 취소되지 않고 무한히 LS 를 계속 조회했다(레이트
|
|
20174
|
+
# 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
|
|
20175
|
+
# 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
|
|
20176
|
+
await self._cleanup_broker_fill_subscriptions()
|
|
20177
|
+
await self._cleanup_jif_subscriptions()
|
|
20178
|
+
await self.context.cleanup_persistent_nodes()
|
|
20179
|
+
await self.context.cleanup_flow_end_nodes()
|
|
20180
|
+
|
|
19698
20181
|
# Cleanup RiskTracker flush
|
|
19699
20182
|
if self.context.risk_tracker:
|
|
19700
20183
|
try:
|
|
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.2 → programgarden-1.29.4}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
{programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/workflow_risk_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|