programgarden 1.22.4__tar.gz → 1.23.1__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 (35) hide show
  1. {programgarden-1.22.4 → programgarden-1.23.1}/PKG-INFO +2 -2
  2. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/client.py +72 -8
  3. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/context.py +89 -3
  4. programgarden-1.23.1/programgarden/deep_fixtures.py +374 -0
  5. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/executor.py +553 -22
  6. {programgarden-1.22.4 → programgarden-1.23.1}/pyproject.toml +2 -2
  7. {programgarden-1.22.4 → programgarden-1.23.1}/README.md +0 -0
  8. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/__init__.py +0 -0
  9. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/binding_validator.py +0 -0
  10. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/database/__init__.py +0 -0
  11. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/database/checkpoint_manager.py +0 -0
  12. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/database/query_builder.py +0 -0
  13. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/database/workflow_position_tracker.py +0 -0
  14. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/database/workflow_risk_tracker.py +0 -0
  15. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/node_runner.py +0 -0
  16. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/plugin/__init__.py +0 -0
  17. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/plugin/sandbox.py +0 -0
  18. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/providers/__init__.py +0 -0
  19. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/providers/llm_errors.py +0 -0
  20. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/providers/llm_provider.py +0 -0
  21. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/reconnect_handler.py +0 -0
  22. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resolver.py +0 -0
  23. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resource/__init__.py +0 -0
  24. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resource/context.py +0 -0
  25. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resource/limiter.py +0 -0
  26. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resource/monitor.py +0 -0
  27. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/resource/throttle.py +0 -0
  28. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/__init__.py +0 -0
  29. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/credential_tools.py +0 -0
  30. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/definition_tools.py +0 -0
  31. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/event_tools.py +0 -0
  32. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/job_tools.py +0 -0
  33. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/registry_tools.py +0 -0
  34. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/tools/sqlite_tools.py +0 -0
  35. {programgarden-1.22.4 → programgarden-1.23.1}/programgarden/validation_recommender.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 1.22.4
3
+ Version: 1.23.1
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  Author: 프로그램동산
6
6
  Author-email: coding@programgarden.com
@@ -15,7 +15,7 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
15
15
  Requires-Dist: litellm (>=1.40.0)
16
16
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
17
17
  Requires-Dist: programgarden-community (>=1.13.8,<2.0.0)
18
- Requires-Dist: programgarden-core (>=1.14.3,<2.0.0)
18
+ Requires-Dist: programgarden-core (>=1.14.5,<2.0.0)
19
19
  Requires-Dist: programgarden-finance (>=1.6.10,<2.0.0)
20
20
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
21
21
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
@@ -4,14 +4,38 @@ ProgramGarden - Main Client
4
4
  Provides user-friendly API
5
5
  """
6
6
 
7
- from typing import Optional, List, Dict, Any
7
+ from typing import Optional, List, Dict, Any, Awaitable, TypeVar
8
8
  import asyncio
9
+ import concurrent.futures
9
10
 
10
11
  from programgarden.resolver import WorkflowResolver, ValidationResult
11
12
  from programgarden.executor import WorkflowExecutor
12
13
  from programgarden_core.bases.listener import ExecutionListener
13
14
  from programgarden_core.models.resource import ResourceLimits
14
15
 
16
+ _T = TypeVar("_T")
17
+
18
+
19
+ def _run_coro_sync(coro: Awaitable[_T]) -> _T:
20
+ """Run an awaitable to completion from synchronous code without disturbing
21
+ the caller thread's event-loop state.
22
+
23
+ ``asyncio.run()`` creates a fresh loop and, on exit, calls
24
+ ``asyncio.set_event_loop(None)`` — leaving the *main thread* with no current
25
+ loop. Any later ``asyncio.get_event_loop()`` in the same process then raises
26
+ "There is no current event loop", which leaks across test boundaries as a
27
+ spurious failure (global-state pollution).
28
+
29
+ Running the coroutine inside a dedicated worker thread keeps that loop
30
+ lifecycle entirely off the caller thread, so the caller's loop state is
31
+ untouched. It also makes the sync wrappers safe to call from within an
32
+ already-running loop (where a direct ``asyncio.run`` would raise). The
33
+ coroutine itself still enforces its own timeout, so this adds no unbounded
34
+ wait.
35
+ """
36
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
37
+ return pool.submit(lambda: asyncio.run(coro)).result()
38
+
15
39
 
16
40
  class ProgramGarden:
17
41
  """
@@ -50,6 +74,44 @@ class ProgramGarden:
50
74
  """
51
75
  return self.resolver.validate(definition)
52
76
 
77
+ def validate_deep(
78
+ self,
79
+ definition: Dict[str, Any],
80
+ *,
81
+ fixtures: Optional[Dict[str, Any]] = None,
82
+ timeout: float = 15.0,
83
+ ) -> ValidationResult:
84
+ """Deep-validate a workflow via virtual full-execution (never raises).
85
+
86
+ Runs the workflow once, end-to-end, in ``deep_validate`` mode (a strict
87
+ superset of ``dry_run``): no real order is ever placed, no notification is
88
+ dispatched, realtime/data nodes return schema-shaped fixtures so the flow
89
+ completes without waiting for live events or hitting the broker network,
90
+ and node failures are accumulated rather than aborting on the first one.
91
+ The result blocks (``is_valid=False``) if any node errors or the flow
92
+ does not run to completion.
93
+
94
+ Args:
95
+ definition: Workflow definition (JSON dict).
96
+ fixtures: Optional per-node fixture overrides, keyed by node id or
97
+ node type (merged shallowly on top of the default fixture).
98
+ timeout: Hard timeout (seconds) for the single validation pass.
99
+
100
+ Returns:
101
+ ValidationResult — ``errors`` carry structured per-node ErrorInfo;
102
+ ``is_valid`` is True only when nothing failed and the flow completed.
103
+
104
+ Example:
105
+ >>> pg = ProgramGarden()
106
+ >>> result = pg.validate_deep(workflow)
107
+ >>> if not result.is_valid:
108
+ ... for err in result.errors:
109
+ ... print(err.short())
110
+ """
111
+ return _run_coro_sync(
112
+ self.executor.deep_validate(definition, fixtures=fixtures, timeout=timeout)
113
+ )
114
+
53
115
  def run(
54
116
  self,
55
117
  definition: Dict[str, Any],
@@ -105,19 +167,21 @@ class ProgramGarden:
105
167
  resource_limits=limits,
106
168
  storage_dir=storage_dir,
107
169
  )
108
-
170
+
109
171
  if wait:
110
- # Wait for completion
111
- import asyncio
112
- start_time = asyncio.get_event_loop().time() if timeout else None
172
+ # Wait for completion. Use the running loop's clock (get_running_loop
173
+ # is always valid here — we are inside the coroutine) rather than
174
+ # get_event_loop, which is deprecated and loop-state sensitive.
175
+ loop = asyncio.get_running_loop()
176
+ start_time = loop.time() if timeout else None
113
177
  while job.status in ("pending", "running"):
114
178
  await asyncio.sleep(0.1)
115
- if timeout and asyncio.get_event_loop().time() - start_time > timeout:
179
+ if timeout and loop.time() - start_time > timeout:
116
180
  break
117
-
181
+
118
182
  return job.get_state()
119
183
 
120
- return asyncio.run(_run())
184
+ return _run_coro_sync(_run())
121
185
 
122
186
  async def run_async(
123
187
  self,
@@ -257,6 +257,13 @@ class ExecutionContext:
257
257
  # Shutdown flag: cleanup 진행 중/완료 시 실시간 콜백 차단
258
258
  self._shutdown: bool = False
259
259
 
260
+ # deep_validate: unresolved {{ }} bindings collected during the virtual
261
+ # full-execution pass. Each entry: {"node_id", "expression", "reason"}.
262
+ # Drained by `executor.deep_validate` into DEEP_VALIDATION_BINDING_UNRESOLVED
263
+ # errors. Only populated in deep mode (runtime/dry_run keep the old
264
+ # warn-and-continue behaviour untouched).
265
+ self._deep_unresolved_bindings: List[Dict[str, str]] = []
266
+
260
267
  self._build_dag_index(workflow_edges, workflow_nodes)
261
268
 
262
269
  # === Storage Directory ===
@@ -289,14 +296,74 @@ class ExecutionContext:
289
296
 
290
297
  @property
291
298
  def is_dry_run(self) -> bool:
292
- """Workflow is running in dry_run mode.
299
+ """Workflow is running in dry_run mode (deep_validate is a superset).
293
300
 
294
301
  When True, side-effectful nodes (주문/Realtime/알림) skip external calls
295
302
  and return simulated responses. Query/백테스트 nodes still execute normally.
296
303
 
297
- Enable via ``context_params={"dry_run": True}`` in ``pg.run_async``.
304
+ deep_validate is a strict superset of dry_run: enabling ``deep_validate``
305
+ turns this True so every existing dry_run guard (order simulation, event
306
+ loop skip, position/risk tracker skip, MESSAGING simulation, SQL write
307
+ block) fires automatically — there is no order/notify path that reaches a
308
+ real broker call in deep mode.
309
+
310
+ Enable via ``context_params={"dry_run": True}`` (or
311
+ ``{"deep_validate": True}``) in ``pg.run_async``.
298
312
  """
299
- return bool(self.context_params.get("dry_run", False))
313
+ return bool(
314
+ self.context_params.get("dry_run", False)
315
+ or self.context_params.get("deep_validate", False)
316
+ )
317
+
318
+ @property
319
+ def is_deep_validate(self) -> bool:
320
+ """Workflow is running in deep_validate (virtual full-execution) mode.
321
+
322
+ Deep validate is the strict-blocking ``deep_validate`` superset of
323
+ dry_run. This property is True ONLY for deep mode (not plain dry_run), so
324
+ executors can take the deep-only branch: inject fixture data for
325
+ realtime/data nodes (so the flow completes without waiting for events
326
+ that never arrive) and accumulate per-node errors instead of aborting on
327
+ the first failure.
328
+
329
+ Enable via ``context_params={"deep_validate": True}``.
330
+ """
331
+ return bool(self.context_params.get("deep_validate", False))
332
+
333
+ def get_deep_fixture(self, node_id: str, node_type: str) -> Optional[Dict[str, Any]]:
334
+ """Return a caller-supplied deep-validate fixture override, if any.
335
+
336
+ Callers may pass ``context_params={"deep_fixtures": {key: {...}}}`` where
337
+ ``key`` is either a node id or a node type. Node id takes precedence over
338
+ node type. Returns None when no override exists (executor then uses its
339
+ own schema-based default fixture).
340
+ """
341
+ fixtures = self.context_params.get("deep_fixtures")
342
+ if not isinstance(fixtures, dict):
343
+ return None
344
+ override = fixtures.get(node_id)
345
+ if override is None:
346
+ override = fixtures.get(node_type)
347
+ return override if isinstance(override, dict) else None
348
+
349
+ def record_deep_unresolved_binding(self, node_id: str, expression: str, reason: str) -> None:
350
+ """Record a ``{{ }}`` binding that could not be evaluated during a
351
+ deep_validate pass. No-op outside deep mode so runtime/dry_run are
352
+ unaffected. Deduplicated on (node_id, expression) so an auto-iterate that
353
+ re-evaluates the same literal N times yields a single entry.
354
+ """
355
+ if not self.is_deep_validate:
356
+ return
357
+ for entry in self._deep_unresolved_bindings:
358
+ if entry.get("node_id") == node_id and entry.get("expression") == expression:
359
+ return
360
+ self._deep_unresolved_bindings.append(
361
+ {"node_id": node_id, "expression": expression, "reason": reason}
362
+ )
363
+
364
+ def get_deep_unresolved_bindings(self) -> List[Dict[str, str]]:
365
+ """Return the deduplicated unresolved-binding records gathered in deep mode."""
366
+ return list(self._deep_unresolved_bindings)
300
367
 
301
368
  # === DAG Index Building ===
302
369
 
@@ -1567,6 +1634,10 @@ class ExecutionContext:
1567
1634
  data_schema: Optional[Dict[str, Any]] = None,
1568
1635
  ) -> None:
1569
1636
  """Notify all listeners about display data from DisplayNode."""
1637
+ # dry_run/deep_validate: suppress outbound display notifications centrally
1638
+ # (no listener side-effects during virtual validation runs).
1639
+ if self.is_dry_run:
1640
+ return
1570
1641
  logger.debug(f"📡 notify_display_data: {node_id} ({chart_type})")
1571
1642
 
1572
1643
  if not self._listeners:
@@ -1612,6 +1683,9 @@ class ExecutionContext:
1612
1683
 
1613
1684
  async def notify_llm_stream(self, event: LLMStreamEvent) -> None:
1614
1685
  """LLM 토큰 스트리밍 이벤트 전파. UI 실시간 타이핑 효과용."""
1686
+ # dry_run/deep_validate: suppress outbound stream notifications centrally.
1687
+ if self.is_dry_run:
1688
+ return
1615
1689
  if not self._listeners:
1616
1690
  return
1617
1691
  for listener in self._listeners:
@@ -1623,6 +1697,9 @@ class ExecutionContext:
1623
1697
 
1624
1698
  async def notify_token_usage(self, event: TokenUsageEvent) -> None:
1625
1699
  """토큰 사용량 이벤트 전파. 비용 추적 및 모니터링용."""
1700
+ # dry_run/deep_validate: suppress outbound token-usage notifications.
1701
+ if self.is_dry_run:
1702
+ return
1626
1703
  if not self._listeners:
1627
1704
  return
1628
1705
  for listener in self._listeners:
@@ -1634,6 +1711,9 @@ class ExecutionContext:
1634
1711
 
1635
1712
  async def notify_ai_tool_call(self, event: AIToolCallEvent) -> None:
1636
1713
  """AI Tool 호출 이벤트 전파. UI에서 Tool 호출 상태 표시용."""
1714
+ # dry_run/deep_validate: suppress outbound tool-call notifications.
1715
+ if self.is_dry_run:
1716
+ return
1637
1717
  if not self._listeners:
1638
1718
  return
1639
1719
  for listener in self._listeners:
@@ -1727,6 +1807,12 @@ class ExecutionContext:
1727
1807
  data: Optional[Dict[str, Any]] = None,
1728
1808
  ) -> None:
1729
1809
  """편의 메서드: NotificationEvent 생성 및 전파."""
1810
+ # dry_run/deep_validate: suppress outbound notifications centrally so no
1811
+ # external messaging (telegram/kakao/AI) is dispatched during a virtual
1812
+ # validation run. Covers workflow lifecycle (started/completed/failed) and
1813
+ # all convenience-method emitters in one place.
1814
+ if self.is_dry_run:
1815
+ return
1730
1816
  event = NotificationEvent(
1731
1817
  job_id=self.job_id,
1732
1818
  category=category,
@@ -0,0 +1,374 @@
1
+ """Deep-validate fixture generators (virtual full-execution).
2
+
3
+ `deep_validate` runs a workflow once, end-to-end, without touching the broker
4
+ network or placing any real order. Realtime / data-fetch nodes that would
5
+ normally wait for live events or hit the LS API instead return a *fixture* — a
6
+ schema-shaped default payload — so the flow keeps flowing to downstream nodes
7
+ and field/type/flow integrity can be checked.
8
+
9
+ Every generator here is pure and synchronous: it derives a reasonable default
10
+ from the node config (mostly the requested symbols), never performs I/O, and
11
+ matches the node's real output port shape so downstream consumers see the same
12
+ keys they would at runtime.
13
+
14
+ Callers may override any fixture via
15
+ ``context_params={"deep_fixtures": {node_id_or_type: {...}}}``; that override is
16
+ applied by the executor (``context.get_deep_fixture``) *before* falling back to
17
+ these defaults.
18
+
19
+ All user-facing strings in this module must be English.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Any, Dict, List, Optional
25
+
26
+
27
+ # A single deterministic "as-of" date for fixture time series, so deep runs are
28
+ # reproducible and do not depend on wall-clock during a validation pass.
29
+ _FIXTURE_ANCHOR = datetime(2025, 1, 2, tzinfo=timezone.utc)
30
+
31
+
32
+ def _norm_symbols(raw: Any) -> List[Dict[str, str]]:
33
+ """Normalise a symbols input into ``[{"symbol", "exchange"}]``.
34
+
35
+ Accepts the shapes the executors accept: a list of strings, a list of
36
+ ``{"symbol", "exchange"}`` dicts, or a single such dict. Returns at least one
37
+ entry (a placeholder) so a fixture is always non-empty — an empty list would
38
+ let an unrelated "no symbols" error mask the integrity check.
39
+ """
40
+ out: List[Dict[str, str]] = []
41
+ if isinstance(raw, dict):
42
+ raw = [raw]
43
+ if isinstance(raw, list):
44
+ for entry in raw:
45
+ if isinstance(entry, dict):
46
+ sym = str(entry.get("symbol", "") or "").strip()
47
+ exch = str(entry.get("exchange", "") or "").strip()
48
+ if sym:
49
+ out.append({"symbol": sym, "exchange": exch or "NASDAQ"})
50
+ elif isinstance(entry, str) and entry.strip():
51
+ out.append({"symbol": entry.strip(), "exchange": "NASDAQ"})
52
+ if not out:
53
+ out.append({"symbol": "AAPL", "exchange": "NASDAQ"})
54
+ return out
55
+
56
+
57
+ def _config_symbols(config: Dict[str, Any]) -> Any:
58
+ """Best-effort symbols extraction from a node config (no context I/O)."""
59
+ for key in ("symbols", "symbol"):
60
+ if config.get(key):
61
+ return config[key]
62
+ return None
63
+
64
+
65
+ # Number of OHLCV bars every fixture time-series carries. Must comfortably
66
+ # exceed the lookback of common indicators (RSI(14), Bollinger(20), SR windows,
67
+ # ATR(14), TSMOM(60)) so a deep run computes a *real* (non-None / non-neutral)
68
+ # indicator value instead of a "data too short" sentinel — a thin series was the
69
+ # Phase 1 source of deep-validate false positives. Capped to keep the single
70
+ # deep pass fast (it runs every condition plugin over every symbol within a hard
71
+ # 15 s box); a handful of corpus indicators use a longer lookback (e.g. calmar
72
+ # 252) and legitimately fall back to a neutral value — that does not break flow.
73
+ _FIXTURE_BARS = 64
74
+
75
+
76
+ def _ohlcv_series(symbol: str, *, n: int = _FIXTURE_BARS, base: float = 100.0) -> List[Dict[str, Any]]:
77
+ """Build a deterministic OHLCV series for one symbol (oldest bar first).
78
+
79
+ The close path rises for the first ~⅔ of the window then declines into the
80
+ final bar. That gives indicators something non-trivial to chew on (a real RSI
81
+ in the 25–75 band, a real Bollinger position, a real ATR) rather than a
82
+ perfectly monotonic ramp that pins RSI to 0/100. The shape is *deterministic*
83
+ (no randomness) so deep runs stay reproducible.
84
+ """
85
+ bars: List[Dict[str, Any]] = []
86
+ peak = int(n * 0.65)
87
+ price = base
88
+ for i in range(n):
89
+ day = _FIXTURE_ANCHOR + timedelta(days=i)
90
+ # Rise toward the peak, then ease back down — a gentle mean-reverting arc.
91
+ if i <= peak:
92
+ price = base + i * 0.8
93
+ else:
94
+ price = base + peak * 0.8 - (i - peak) * 1.1
95
+ price = max(price, base * 0.5)
96
+ bars.append(
97
+ {
98
+ "date": day.strftime("%Y%m%d"),
99
+ "open": round(price - 0.5, 2),
100
+ "high": round(price + 1.0, 2),
101
+ "low": round(price - 1.0, 2),
102
+ "close": round(price, 2),
103
+ "volume": 1_000_000 + i * 1000,
104
+ }
105
+ )
106
+ return bars
107
+
108
+
109
+ def _time_series_entries(config: Dict[str, Any], symbols_raw: Any = None) -> List[Dict[str, Any]]:
110
+ """Build the per-symbol ``{symbol, exchange, time_series:[bars]}`` list that
111
+ HistoricalDataNode emits at runtime. Downstream ConditionNode auto-iterates
112
+ this list, so each entry must carry the keys its ``items.extract`` reads
113
+ (``symbol``/``exchange``) plus a ``time_series`` of OHLCV bars.
114
+ """
115
+ syms = _norm_symbols(symbols_raw if symbols_raw is not None else _config_symbols(config))
116
+ entries: List[Dict[str, Any]] = []
117
+ for idx, entry in enumerate(syms):
118
+ entries.append(
119
+ {
120
+ "symbol": entry["symbol"],
121
+ "exchange": entry["exchange"],
122
+ "time_series": _ohlcv_series(entry["symbol"], base=100.0 + idx * 10),
123
+ }
124
+ )
125
+ return entries
126
+
127
+
128
+ def real_market_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
129
+ """RealMarketDataNode deep fixture.
130
+
131
+ Real shape: ``{"symbols", "ohlcv_data": {SYM: [bars]}, "data": {SYM: [bars]}}``.
132
+ """
133
+ syms = _norm_symbols(symbols_raw if symbols_raw is not None else _config_symbols(config))
134
+ ohlcv: Dict[str, List[Dict[str, Any]]] = {}
135
+ for idx, entry in enumerate(syms):
136
+ ohlcv[entry["symbol"]] = _ohlcv_series(entry["symbol"], base=100.0 + idx * 10)
137
+ return {
138
+ "symbols": syms,
139
+ "ohlcv_data": ohlcv,
140
+ "data": dict(ohlcv),
141
+ }
142
+
143
+
144
+ def market_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
145
+ """MarketDataNode (REST current-price) deep fixture.
146
+
147
+ Real shape: ``{"values": [{symbol, exchange, price, change, change_pct,
148
+ volume, open, high, low, close, per, eps}]}``.
149
+ """
150
+ syms = _norm_symbols(symbols_raw if symbols_raw is not None else _config_symbols(config))
151
+ values: List[Dict[str, Any]] = []
152
+ for idx, entry in enumerate(syms):
153
+ price = round(100.0 + idx * 10, 2)
154
+ values.append(
155
+ {
156
+ "symbol": entry["symbol"],
157
+ "exchange": entry["exchange"],
158
+ "price": price,
159
+ "change": 1.0,
160
+ "change_pct": 1.0,
161
+ "volume": 1_000_000,
162
+ "open": round(price - 0.5, 2),
163
+ "high": round(price + 1.0, 2),
164
+ "low": round(price - 1.0, 2),
165
+ "close": price,
166
+ "per": 15.0,
167
+ "eps": 5.0,
168
+ }
169
+ )
170
+ return {"values": values}
171
+
172
+
173
+ def historical_data_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
174
+ """HistoricalDataNode deep fixture.
175
+
176
+ Real shape (executor.py ``HistoricalDataNodeExecutor.execute`` return):
177
+ ``{"value": {symbol, exchange, time_series:[bars]} | None,
178
+ "values": [{symbol, exchange, time_series:[bars]}, ...],
179
+ "symbols": [str, ...], "period": str, "interval": str}``.
180
+
181
+ Each ``time_series`` bar is ``{date, open, high, low, close, volume}``.
182
+ Downstream ConditionNode auto-iterates ``values`` and reads
183
+ ``item.time_series`` / ``item.symbol``, so this shape must mirror the runtime
184
+ output exactly (the prior ``ohlcv_data`` map shape silently starved the
185
+ ConditionNode and produced deep false positives).
186
+ """
187
+ if symbols_raw is None:
188
+ symbols_raw = config.get("symbol") or _config_symbols(config)
189
+ entries = _time_series_entries(config, symbols_raw)
190
+ single = entries[0] if len(entries) == 1 else None
191
+ return {
192
+ "value": single,
193
+ "values": entries,
194
+ "symbols": [e["symbol"] for e in entries],
195
+ "period": str(config.get("period", "1d")),
196
+ "interval": str(config.get("interval", config.get("period", "1d"))),
197
+ }
198
+
199
+
200
+ def _fixture_balance(currency: str = "USD") -> Dict[str, Any]:
201
+ """Per-currency balance map (RealAccountNode shape) + flat convenience keys.
202
+
203
+ Mirrors the real RealAccountNode balance: a currency-keyed map plus a
204
+ ``_summary``. Also carries flat keys (``orderable_amount``, ``total_pnl_rate``)
205
+ that PositionSizingNode / IfNode aggregate checks read directly, so neither
206
+ consumer shape sees a missing field in deep mode.
207
+ """
208
+ return {
209
+ currency: {
210
+ "deposit": 100000.0,
211
+ "orderable_amount": 100000.0,
212
+ "eval_amount": 105000.0,
213
+ "pnl_amount": 5000.0,
214
+ "pnl_rate": 5.0,
215
+ },
216
+ "_summary": {
217
+ "total_deposit": 100000.0,
218
+ "total_eval_amount": 105000.0,
219
+ "total_pnl_amount": 5000.0,
220
+ },
221
+ # Flat convenience keys some consumers (PositionSizing, aggregate IfNode)
222
+ # read directly.
223
+ "cash": 100000.0,
224
+ "total_value": 105000.0,
225
+ "orderable_amount": 100000.0,
226
+ "total_pnl_rate": 5.0,
227
+ }
228
+
229
+
230
+ def _fixture_positions(config: Dict[str, Any], symbols_raw: Any = None) -> List[Dict[str, Any]]:
231
+ """Held positions fixture mirroring the real Account/RealAccount position dict.
232
+
233
+ The FIRST position is deliberately *losing* (negative pnl_rate) so per-position
234
+ risk conditions (StopLoss / TrailingStop) trigger during a deep run — a flat
235
+ "everything is +5%" book would never exercise the stop-loss → order → notify
236
+ branch, leaving its ``{{ item.* }}`` bindings unreached (a false negative).
237
+ """
238
+ syms = _norm_symbols(symbols_raw if symbols_raw is not None else _config_symbols(config))
239
+ positions: List[Dict[str, Any]] = []
240
+ for idx, entry in enumerate(syms):
241
+ avg = round(100.0 + idx * 10, 2)
242
+ # First holding is underwater (-8%), the rest are in profit (+5%).
243
+ cur = round(avg * 0.92, 2) if idx == 0 else round(avg * 1.05, 2)
244
+ qty = 10
245
+ positions.append(
246
+ {
247
+ "symbol": entry["symbol"],
248
+ "exchange": entry["exchange"],
249
+ "name": entry["symbol"],
250
+ "qty": qty,
251
+ "quantity": qty, # NewOrderNode compat key
252
+ "direction": "long",
253
+ "close_side": "sell",
254
+ "avg_price": avg,
255
+ "entry_price": avg,
256
+ "current_price": cur,
257
+ "price": cur,
258
+ "pnl_rate": round((cur - avg) / avg * 100, 2),
259
+ "pnl_amount": round((cur - avg) * qty, 2),
260
+ "eval_amount": round(cur * qty, 2),
261
+ "purchase_amount": round(avg * qty, 2),
262
+ "currency": "USD",
263
+ "market": entry["exchange"],
264
+ "market_code": "82",
265
+ }
266
+ )
267
+ return positions
268
+
269
+
270
+ def real_account_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
271
+ """RealAccountNode deep fixture.
272
+
273
+ Real shape: ``{"positions": [...], "balance": {...}, "open_orders": {}}``.
274
+ """
275
+ return {
276
+ "positions": _fixture_positions(config, symbols_raw),
277
+ "balance": _fixture_balance(),
278
+ "open_orders": {},
279
+ }
280
+
281
+
282
+ def account_fixture(config: Dict[str, Any], symbols_raw: Any = None) -> Dict[str, Any]:
283
+ """AccountNode (REST) deep fixture.
284
+
285
+ Real shape: ``{"positions": [...], "balance": {...}}``.
286
+ """
287
+ return {
288
+ "positions": _fixture_positions(config, symbols_raw),
289
+ "balance": _fixture_balance(),
290
+ }
291
+
292
+
293
+ def open_orders_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
294
+ """OpenOrdersNode deep fixture (no pending orders — real LS query blocked).
295
+
296
+ Real shape: ``{"open_orders": [...], "count": N}``.
297
+ """
298
+ return {"open_orders": [], "count": 0}
299
+
300
+
301
+ def real_order_event_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
302
+ """RealOrderEventNode deep fixture (one simulated fill).
303
+
304
+ Real shape includes a ``filled`` payload + ``status`` field.
305
+ """
306
+ ts = _FIXTURE_ANCHOR.isoformat()
307
+ return {
308
+ "status": "체결",
309
+ "filled": {
310
+ "timestamp": ts,
311
+ "symbol": "AAPL",
312
+ "order_no": "DEEP-0001",
313
+ "side": "buy",
314
+ "order_qty": 10,
315
+ "order_price": 100.0,
316
+ "status": "체결",
317
+ },
318
+ }
319
+
320
+
321
+ def market_status_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
322
+ """MarketStatusNode deep fixture (markets open).
323
+
324
+ Real shape: ``{"statuses": [{market, status}]}``.
325
+ """
326
+ return {
327
+ "statuses": [
328
+ {"market": "NASDAQ", "status": "OPEN"},
329
+ {"market": "NYSE", "status": "OPEN"},
330
+ ],
331
+ }
332
+
333
+
334
+ def broker_connection_fixture(
335
+ node_type: str,
336
+ config: Dict[str, Any],
337
+ ) -> Dict[str, Any]:
338
+ """BrokerNode deep fixture connection (no LS login, no fill-price sync).
339
+
340
+ Mirrors the real success shape ``{"connected": True, "connection": {...}}``
341
+ so downstream nodes that read ``connection`` (broker auto-injection) keep
342
+ flowing. Credentials are placeholders — every downstream broker-bound node is
343
+ itself short-circuited in deep mode, so they are never used for a real call.
344
+ """
345
+ if "Futures" in node_type:
346
+ product = "overseas_futures"
347
+ elif "Korea" in node_type:
348
+ product = "korea_stock"
349
+ else:
350
+ product = config.get("product", "overseas_stock")
351
+ paper_trading = bool(config.get("paper_trading", False)) if product != "korea_stock" else False
352
+ return {
353
+ "connected": True,
354
+ "connection": {
355
+ "provider": config.get("provider", "ls-sec.co.kr"),
356
+ "product": product,
357
+ "paper_trading": paper_trading,
358
+ "appkey": "DEEP_VALIDATE_APPKEY",
359
+ "appsecret": "DEEP_VALIDATE_APPSECRET",
360
+ },
361
+ }
362
+
363
+
364
+ def apply_override(default: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
365
+ """Shallow-merge a caller override on top of a default fixture.
366
+
367
+ Override keys win; unspecified keys keep the schema-shaped default so the
368
+ flow still has every output port populated.
369
+ """
370
+ if not override:
371
+ return default
372
+ merged = dict(default)
373
+ merged.update(override)
374
+ return merged