programgarden 1.22.4__tar.gz → 1.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-1.22.4 → programgarden-1.23.0}/PKG-INFO +2 -2
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/client.py +72 -8
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/context.py +89 -3
- programgarden-1.23.0/programgarden/deep_fixtures.py +374 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/executor.py +444 -9
- {programgarden-1.22.4 → programgarden-1.23.0}/pyproject.toml +2 -2
- {programgarden-1.22.4 → programgarden-1.23.0}/README.md +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/binding_validator.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/database/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/node_runner.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resolver.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resource/context.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-1.22.4 → programgarden-1.23.0}/programgarden/validation_recommender.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
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.
|
|
18
|
+
Requires-Dist: programgarden-core (>=1.14.4,<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
|
-
|
|
112
|
-
|
|
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
|
|
179
|
+
if timeout and loop.time() - start_time > timeout:
|
|
116
180
|
break
|
|
117
|
-
|
|
181
|
+
|
|
118
182
|
return job.get_state()
|
|
119
183
|
|
|
120
|
-
return
|
|
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
|
-
|
|
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(
|
|
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
|