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.
Files changed (37) hide show
  1. {programgarden-1.29.2 → programgarden-1.29.4}/PKG-INFO +1 -1
  2. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/__init__.py +1 -1
  3. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/executor.py +576 -93
  4. {programgarden-1.29.2 → programgarden-1.29.4}/pyproject.toml +1 -1
  5. {programgarden-1.29.2 → programgarden-1.29.4}/README.md +0 -0
  6. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/binding_validator.py +0 -0
  7. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/client.py +0 -0
  8. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/code_worker.py +0 -0
  9. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/context.py +0 -0
  10. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/deep_fixtures.py +0 -0
  16. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/node_runner.py +0 -0
  17. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/plugin/__init__.py +0 -0
  18. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/plugin/sandbox.py +0 -0
  19. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/__init__.py +0 -0
  20. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/llm_errors.py +0 -0
  21. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/providers/llm_provider.py +0 -0
  22. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/reconnect_handler.py +0 -0
  23. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resolver.py +0 -0
  24. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/__init__.py +0 -0
  25. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/context.py +0 -0
  26. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/limiter.py +0 -0
  27. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/monitor.py +0 -0
  28. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/resource/throttle.py +0 -0
  29. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/semantic_rules.py +0 -0
  30. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/__init__.py +0 -0
  31. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/credential_tools.py +0 -0
  32. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/definition_tools.py +0 -0
  33. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/event_tools.py +0 -0
  34. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/job_tools.py +0 -0
  35. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/registry_tools.py +0 -0
  36. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/tools/sqlite_tools.py +0 -0
  37. {programgarden-1.29.2 → programgarden-1.29.4}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.29.2
3
+ Version: 1.29.4
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -91,7 +91,7 @@ except ImportError:
91
91
  # Community package not installed
92
92
  pass
93
93
 
94
- __version__ = "1.29.0"
94
+ __version__ = "1.29.3"
95
95
  __all__ = [
96
96
  # Core
97
97
  "ProgramGarden",
@@ -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
- print(f"\n📋 {title or 'Data Summary'} [{now}]")
8311
- print("=" * 60)
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
- print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8453
+ _safe_print(json.dumps(serialized, indent=2, ensure_ascii=False, default=str))
8326
8454
  except Exception as e:
8327
- print(f"(직렬화 실패: {e})")
8328
- print(str(data)[:500])
8329
- print("=" * 60 + "\n")
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
- print(f"\n📊 {title or 'Table'} [{now}]")
8341
- print("=" * 80)
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
- print(header)
8356
- print("-" * 80)
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
- print(values)
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
- print(header)
8378
- print("-" * 80)
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
- print(values)
8511
+ _safe_print(values)
8384
8512
  else:
8385
8513
  # flat dict
8386
8514
  for k, v in list(data.items())[:limit]:
8387
- print(f" {k:<25}: {self._format_value(v)}")
8515
+ _safe_print(f" {k:<25}: {self._format_value(v)}")
8388
8516
 
8389
- print("=" * 80 + "\n")
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
- print(f"\n📈 {title or 'Line Chart'} [{now}]")
8404
- print("=" * 60)
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
- print(f"📊 데이터 포인트: {len(data)}개")
8410
- print(f"📅 X축 범위: {first.get(x_field)} ~ {last.get(x_field)}")
8411
- print(f"📈 Y축({y_field}) 시작: {self._format_value(first.get(y_field))}")
8412
- print(f"📈 Y축({y_field}) 끝: {self._format_value(last.get(y_field))}")
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
- print(f"📈 최대: {self._format_value(max(values))}")
8418
- print(f"📉 최소: {self._format_value(min(values))}")
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
- print(f"\n🎯 시그널 ({len(signals)}개):")
8552
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8425
8553
  for sig in signals[:10]: # 최대 10개만 표시
8426
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8554
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8427
8555
  if len(signals) > 10:
8428
- print(f" ... 외 {len(signals) - 10}개")
8556
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8429
8557
  else:
8430
- print("(데이터 없음)")
8558
+ _safe_print("(데이터 없음)")
8431
8559
 
8432
- print("=" * 60 + "\n")
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
- print(f"\n📈 {title or 'Multi-Line Chart'} [{now}]")
8457
- print("=" * 80)
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
- print(f"📊 시리즈 수: {len(series_items)}개 (limit: {limit})")
8483
- print("-" * 80)
8484
- print(f"{'Series':<12} | {'Points':<8} | {f'First {y_field}':<15} | {f'Last {y_field}':<15}")
8485
- print("-" * 80)
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
- print(f"{symbol:<12} | {len(rows):<8} | {self._format_value(first_val):<15} | {self._format_value(last_val):<15}")
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
- print(f"\n🎯 시그널 ({len(signals)}개):")
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
- print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8628
+ _safe_print(f" {sig['marker']} {sig['x']}{series_info} ({sig['signal']}/{sig['side']})")
8501
8629
  if len(signals) > 15:
8502
- print(f" ... 외 {len(signals) - 15}개")
8630
+ _safe_print(f" ... 외 {len(signals) - 15}개")
8503
8631
 
8504
- print("=" * 80 + "\n")
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
- print(f"\n📊 {title or 'Candlestick Chart'} [{now}]")
8531
- print("=" * 80)
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
- print(f"📊 캔들 수: {len(data)}개")
8537
- print(f"📅 기간: {first.get(date_field)} ~ {last.get(date_field)}")
8538
- print(f"💵 시작 종가: {self._format_value(first.get(close_field))}")
8539
- print(f"💵 최종 종가: {self._format_value(last.get(close_field))}")
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
- print(f"📈 변동률: {color}{sign}{pct_change:.2f}%\033[0m")
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
- print(f"\n🎯 시그널 ({len(signals)}개):")
8682
+ _safe_print(f"\n🎯 시그널 ({len(signals)}개):")
8555
8683
  for sig in signals[:10]: # 최대 10개만 표시
8556
- print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8684
+ _safe_print(f" {sig['marker']} {sig['x']} ({sig['signal']}/{sig['side']})")
8557
8685
  if len(signals) > 10:
8558
- print(f" ... 외 {len(signals) - 10}개")
8686
+ _safe_print(f" ... 외 {len(signals) - 10}개")
8559
8687
  else:
8560
- print("(데이터 없음)")
8688
+ _safe_print("(데이터 없음)")
8561
8689
 
8562
- print("=" * 80 + "\n")
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
- print(f"\n📊 {title or 'Bar Chart'} [{now}]")
8586
- print("=" * 60)
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
- print(f"{'Bar':<20} | {y_field}")
8590
- print("-" * 60)
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
- print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8722
+ _safe_print(f"{str(x_val):<20} | {self._format_value(y_val)}")
8595
8723
  elif isinstance(data, dict):
8596
- print(f"{'Key':<20} | {'Value'}")
8597
- print("-" * 60)
8724
+ _safe_print(f"{'Key':<20} | {'Value'}")
8725
+ _safe_print("-" * 60)
8598
8726
  for k, v in list(data.items())[:20]:
8599
- print(f"{str(k):<20} | {self._format_value(v)}")
8727
+ _safe_print(f"{str(k):<20} | {self._format_value(v)}")
8600
8728
 
8601
- print("=" * 60 + "\n")
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
- print(f"⚠️ Job {self.job_id} cancelled")
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
- print(f"❌ Job {self.job_id} failed: {e}")
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
- traceback.print_exc()
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
- print(f"🔄 Executing main flow: {self.workflow.execution_order}")
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
- print(f" ⏭ Skipping restored node: {node_id} ({node.node_type})")
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
- print(f" ⏭ Skipping branch node: {node_id} (handled by SplitNode)")
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
- print(f" ⏭ Skipping AggregateNode: {node_id} (already executed by SplitNode)")
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
- print(f" ⏭ Skipping node: {node_id} (IfNode branch not taken)")
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
- print(f" ▶ Executing node: {node_id} ({node.node_type})")
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
- print(f" 🔀 IfNode {node_id}: branch={taken}, skipping {new_skips}")
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
- await self.context.notify_node_state(
18238
- node_id=node_id,
18239
- node_type=node.node_type,
18240
- state=NodeState.RUNNING if is_stay_connected else NodeState.COMPLETED,
18241
- outputs=outputs,
18242
- duration_ms=duration_ms,
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
- print(f" 🔄 Auto-iterate: {node_id} ({node.node_type}) - {total} items")
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
- print(f" [{idx+1}/{total}] Processing: {item_label}")
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
- print(f" ✅ Auto-iterate complete: {len(all_results)} results merged")
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
- print(f" 🔀 Executing SplitNode branch: {split_id}")
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
- print(f" 📦 SplitNode: {total} items, parallel={parallel}, delay_ms={delay_ms}")
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
- print(f" 🔗 Executing AggregateNode: {aggregate_id}")
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
- print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 실시간 업데이트 이벤트 수신: trigger_nodes={event.trigger_nodes}")
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
- print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19940
+ _safe_print(f" → 자동 탐색된 trigger_nodes: {trigger_nodes}")
19468
19941
 
19469
19942
  if not trigger_nodes:
19470
- print(f" ⚠️ 트리거할 노드 없음, 스킵")
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
- print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
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
- print(f" → 실행할 노드 체인: {nodes_to_execute}")
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
- print(f" 🔀 Re-driving split branch: {split_id} → {aggregate_id}")
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
- print(f" ▶ Re-executing: {node_id} ({node.node_type})")
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:
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "1.29.2"
8
+ version = "1.29.4"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
File without changes