programgarden 2.2.0__tar.gz → 2.4.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-2.2.0 → programgarden-2.4.0}/PKG-INFO +3 -3
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/context.py +5 -50
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/deep_fixtures.py +0 -13
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/executor.py +15 -377
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_events.py +7 -1
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_external.py +7 -2
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_semantics.py +7 -4
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_sources.py +1 -39
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_triggers.py +7 -2
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/validation_recommender.py +0 -1
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/validation_replay.py +14 -1
- {programgarden-2.2.0 → programgarden-2.4.0}/pyproject.toml +3 -3
- {programgarden-2.2.0 → programgarden-2.4.0}/README.md +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/binding_validator.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/client.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/code_worker.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/incremental_build.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/node_runner.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_orders.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_scenarios.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/replay_sqlite.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resolver.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/context.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.2.0 → programgarden-2.4.0}/programgarden/valuation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: programgarden
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -15,8 +15,8 @@ Requires-Dist: aiosqlite (>=0.20.0,<0.21.0)
|
|
|
15
15
|
Requires-Dist: croniter (>=6.0.0,<7.0.0)
|
|
16
16
|
Requires-Dist: litellm (>=1.40.0)
|
|
17
17
|
Requires-Dist: lxml (>=6.0.2,<7.0.0)
|
|
18
|
-
Requires-Dist: programgarden-community (>=2.
|
|
19
|
-
Requires-Dist: programgarden-core (>=2.
|
|
18
|
+
Requires-Dist: programgarden-community (>=2.2.0,<3.0.0)
|
|
19
|
+
Requires-Dist: programgarden-core (>=2.4.0,<3.0.0)
|
|
20
20
|
Requires-Dist: programgarden-finance (>=2.0.1,<3.0.0)
|
|
21
21
|
Requires-Dist: psutil (>=6.0.0,<7.0.0)
|
|
22
22
|
Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
|
|
@@ -236,11 +236,6 @@ class ExecutionContext:
|
|
|
236
236
|
# Realtime data buffer
|
|
237
237
|
self._realtime_data: Dict[str, Any] = {}
|
|
238
238
|
|
|
239
|
-
# Market status cache (JIF 기반, MarketStatusNode 전용)
|
|
240
|
-
# key=market canonical name (e.g. "US"), value=dict(jstatus, label,
|
|
241
|
-
# is_open, is_regular_open, is_extended_open, updated_at, jangubun)
|
|
242
|
-
self._market_status_cache: Dict[str, Dict[str, Any]] = {}
|
|
243
|
-
|
|
244
239
|
# Providers
|
|
245
240
|
self._data_provider: Optional[DataProvider] = None
|
|
246
241
|
self._account_provider: Optional[AccountProvider] = None
|
|
@@ -796,47 +791,6 @@ class ExecutionContext:
|
|
|
796
791
|
"""Get realtime data"""
|
|
797
792
|
return self._realtime_data.get(key)
|
|
798
793
|
|
|
799
|
-
# === Market Status (JIF-based) ===
|
|
800
|
-
|
|
801
|
-
def update_market_status(self, market: str, status: Dict[str, Any]) -> None:
|
|
802
|
-
"""Update the cached status snapshot for a canonical market key.
|
|
803
|
-
|
|
804
|
-
Called by MarketStatusNodeExecutor on each JIF event. ``market``
|
|
805
|
-
is one of the 12 canonical keys (KOSPI, US, HK_AM, ...). Unknown
|
|
806
|
-
codes are stored as-is so debugging can still inspect them.
|
|
807
|
-
"""
|
|
808
|
-
|
|
809
|
-
self._market_status_cache[market] = status
|
|
810
|
-
|
|
811
|
-
def get_market_status(self, market: str) -> Optional[Dict[str, Any]]:
|
|
812
|
-
"""Return the latest status snapshot for ``market`` or None if
|
|
813
|
-
no JIF event has been received yet for that market."""
|
|
814
|
-
|
|
815
|
-
return self._market_status_cache.get(market)
|
|
816
|
-
|
|
817
|
-
def is_market_open(
|
|
818
|
-
self, market: str, include_extended: bool = False
|
|
819
|
-
) -> Optional[bool]:
|
|
820
|
-
"""Convenience helper for ``{{ ... is_market_open("US") }}``.
|
|
821
|
-
|
|
822
|
-
Returns ``None`` when no event has been received for the market
|
|
823
|
-
(distinct from ``False``, which means an event said the market
|
|
824
|
-
is closed). Honours the ``include_extended`` flag to cover
|
|
825
|
-
pre/after-market sessions.
|
|
826
|
-
"""
|
|
827
|
-
|
|
828
|
-
status = self._market_status_cache.get(market)
|
|
829
|
-
if not status:
|
|
830
|
-
return None
|
|
831
|
-
if include_extended:
|
|
832
|
-
return bool(status.get("is_extended_open", False))
|
|
833
|
-
return bool(status.get("is_regular_open", False))
|
|
834
|
-
|
|
835
|
-
def get_all_market_statuses(self) -> Dict[str, Dict[str, Any]]:
|
|
836
|
-
"""Shallow copy of the full market status snapshot cache."""
|
|
837
|
-
|
|
838
|
-
return dict(self._market_status_cache)
|
|
839
|
-
|
|
840
794
|
# === Data Query (Provider Delegation) ===
|
|
841
795
|
|
|
842
796
|
async def get_price(self, symbol: str) -> Optional[float]:
|
|
@@ -3395,10 +3349,11 @@ class ExecutionContext:
|
|
|
3395
3349
|
Also provides `input` variable for workflow inputs.
|
|
3396
3350
|
|
|
3397
3351
|
Available variables in expressions:
|
|
3398
|
-
- {{ nodeId.port }}: Node output values
|
|
3399
|
-
|
|
3400
|
-
- {{
|
|
3401
|
-
- {{
|
|
3352
|
+
- {{ nodes.nodeId.port }}: Node output values (the `nodes.` prefix is
|
|
3353
|
+
required — a bare `{{ nodeId.port }}` is an undefined variable)
|
|
3354
|
+
- {{ nodes.nodeId.method() }}: Node output with method chaining
|
|
3355
|
+
- {{ item }}, {{ item.field }}: Current iteration item
|
|
3356
|
+
- {{ index }}, {{ total }}: Iteration context
|
|
3402
3357
|
- {{ input.xxx }}: Workflow input parameters
|
|
3403
3358
|
- {{ context.xxx }}: Runtime context parameters
|
|
3404
3359
|
"""
|
|
@@ -441,19 +441,6 @@ def futures_contract_fixture(
|
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
|
|
444
|
-
def market_status_fixture(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
445
|
-
"""MarketStatusNode deep fixture (markets open).
|
|
446
|
-
|
|
447
|
-
Real shape: ``{"statuses": [{market, status}]}``.
|
|
448
|
-
"""
|
|
449
|
-
return {
|
|
450
|
-
"statuses": [
|
|
451
|
-
{"market": "NASDAQ", "status": "OPEN"},
|
|
452
|
-
{"market": "NYSE", "status": "OPEN"},
|
|
453
|
-
],
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
444
|
def broker_connection_fixture(
|
|
458
445
|
node_type: str,
|
|
459
446
|
config: Dict[str, Any],
|
|
@@ -2253,8 +2253,10 @@ class ScheduleNodeExecutor(NodeExecutorBase):
|
|
|
2253
2253
|
cron_expr = cron_expr.strip()
|
|
2254
2254
|
tz_name = config.get("timezone", "America/New_York")
|
|
2255
2255
|
enabled = config.get("enabled", True)
|
|
2256
|
-
|
|
2257
|
-
|
|
2256
|
+
# None/absent = unbounded: run until the workflow is stopped (owner
|
|
2257
|
+
# 2026-09-26). A provided value is enforced as a cap in the loop below.
|
|
2258
|
+
count = config.get("count") # None → no cycle cap
|
|
2259
|
+
max_duration_hours = config.get("max_duration_hours") # None → no wall-clock cap
|
|
2258
2260
|
|
|
2259
2261
|
if not enabled:
|
|
2260
2262
|
context.log("info", f"Schedule disabled: {cron_expr}", node_id)
|
|
@@ -2289,7 +2291,8 @@ class ScheduleNodeExecutor(NodeExecutorBase):
|
|
|
2289
2291
|
import time as _time
|
|
2290
2292
|
cnt = 0
|
|
2291
2293
|
start_mono = _time.monotonic()
|
|
2292
|
-
|
|
2294
|
+
# None = unbounded; only compute a wall-clock cap when a bound was set.
|
|
2295
|
+
max_duration_sec = max_duration_hours * 3600 if max_duration_hours is not None else None
|
|
2293
2296
|
try:
|
|
2294
2297
|
# second_at_beginning=True로 초 단위 cron도 지원
|
|
2295
2298
|
try:
|
|
@@ -2297,7 +2300,7 @@ class ScheduleNodeExecutor(NodeExecutorBase):
|
|
|
2297
2300
|
except TypeError:
|
|
2298
2301
|
itr = croniter(cron_expr, datetime.now(tz))
|
|
2299
2302
|
|
|
2300
|
-
while cnt < count and context.is_running:
|
|
2303
|
+
while (count is None or cnt < count) and context.is_running:
|
|
2301
2304
|
# dry_run: 루프 진입 즉시 1회 emit 후 종료
|
|
2302
2305
|
if context.is_dry_run:
|
|
2303
2306
|
cnt += 1
|
|
@@ -2317,8 +2320,9 @@ class ScheduleNodeExecutor(NodeExecutorBase):
|
|
|
2317
2320
|
},
|
|
2318
2321
|
)
|
|
2319
2322
|
break
|
|
2320
|
-
# M-6: max_duration_hours 초과 시 스케줄 종료
|
|
2321
|
-
|
|
2323
|
+
# M-6: max_duration_hours 초과 시 스케줄 종료 (한도가 설정된 경우에만;
|
|
2324
|
+
# None 이면 사용자가 워크플로우를 멈출 때까지 계속 실행)
|
|
2325
|
+
if max_duration_sec is not None and (_time.monotonic() - start_mono) >= max_duration_sec:
|
|
2322
2326
|
context.log(
|
|
2323
2327
|
"warning",
|
|
2324
2328
|
f"Schedule max_duration_hours={max_duration_hours}h 초과 - "
|
|
@@ -9635,356 +9639,6 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
|
|
|
9635
9639
|
context.log("error", f"Korea stock order event error: {e}", node_id)
|
|
9636
9640
|
return {"error": str(e)}
|
|
9637
9641
|
|
|
9638
|
-
class MarketStatusNodeExecutor(NodeExecutorBase):
|
|
9639
|
-
"""MarketStatusNode executor — JIF 기반 실시간 시장 상태 추적.
|
|
9640
|
-
|
|
9641
|
-
Broker credential type 무관(해외주식/해외선물/국내주식 중 아무 BrokerNode).
|
|
9642
|
-
broker의 기존 WSS 세션과 별도로 ``Common.real()`` 공용 WebSocket 세션을
|
|
9643
|
-
생성하여 JIF TR 하나를 구독합니다. token_manager 는 broker 로그인에서
|
|
9644
|
-
재사용하므로 중복 로그인 없음.
|
|
9645
|
-
|
|
9646
|
-
stay_connected=True (기본): 구독 유지하며 콜백마다 context._market_status_cache
|
|
9647
|
-
+ set_output 갱신. 워크플로우 종료 시 cleanup_jif_subscriptions 에서 해제.
|
|
9648
|
-
|
|
9649
|
-
stay_connected=False: 최대 5초 기다리며 수신된 이벤트 반영한 snapshot 반환
|
|
9650
|
-
후 즉시 tr_type=4 해제. AI Agent Tool 일회성 조회용.
|
|
9651
|
-
"""
|
|
9652
|
-
|
|
9653
|
-
# {f"{job_id}::{node_id}": {"jif": RealJIF, "real": Real, "stay_connected": bool}}
|
|
9654
|
-
_active_subscriptions: Dict[str, Any] = {}
|
|
9655
|
-
|
|
9656
|
-
# Canonical market list for the convenience boolean ports. Updated in
|
|
9657
|
-
# lock-step with the OutputPort definitions on ``MarketStatusNode``.
|
|
9658
|
-
_SOLO_MARKET_PORTS = (
|
|
9659
|
-
("US", "us_is_open"),
|
|
9660
|
-
("KOSPI", "kospi_is_open"),
|
|
9661
|
-
("KOSDAQ", "kosdaq_is_open"),
|
|
9662
|
-
("KRX_FUTURES", "krx_futures_is_open"),
|
|
9663
|
-
)
|
|
9664
|
-
|
|
9665
|
-
_COMBO_MARKET_PORTS = (
|
|
9666
|
-
("hk_is_open", "HK_AM", "HK_PM"),
|
|
9667
|
-
("cn_is_open", "CN_AM", "CN_PM"),
|
|
9668
|
-
("jp_is_open", "JP_AM", "JP_PM"),
|
|
9669
|
-
)
|
|
9670
|
-
|
|
9671
|
-
async def cleanup_jif_subscriptions(self, job_id: str) -> None:
|
|
9672
|
-
"""Unsubscribe every active JIF stream registered for the given job.
|
|
9673
|
-
|
|
9674
|
-
Mirrors ``BrokerNodeExecutor.cleanup_fill_subscriptions`` so the
|
|
9675
|
-
executor's shutdown sequence can wipe market-status subscriptions
|
|
9676
|
-
without touching unrelated real-time feeds.
|
|
9677
|
-
"""
|
|
9678
|
-
|
|
9679
|
-
keys_to_remove = []
|
|
9680
|
-
for key, info in self._active_subscriptions.items():
|
|
9681
|
-
if not key.startswith(f"{job_id}::"):
|
|
9682
|
-
continue
|
|
9683
|
-
jif = info.get("jif")
|
|
9684
|
-
real = info.get("real")
|
|
9685
|
-
if jif is not None:
|
|
9686
|
-
try:
|
|
9687
|
-
jif.on_remove_jif_message()
|
|
9688
|
-
except (RuntimeError, AttributeError):
|
|
9689
|
-
pass
|
|
9690
|
-
except Exception as e:
|
|
9691
|
-
logger.warning(f"JIF unsubscribe failed for {key}: {e}")
|
|
9692
|
-
if real is not None:
|
|
9693
|
-
try:
|
|
9694
|
-
await real.close()
|
|
9695
|
-
except Exception as e:
|
|
9696
|
-
logger.warning(f"Common.real close failed for {key}: {e}")
|
|
9697
|
-
keys_to_remove.append(key)
|
|
9698
|
-
|
|
9699
|
-
for key in keys_to_remove:
|
|
9700
|
-
del self._active_subscriptions[key]
|
|
9701
|
-
logger.debug(f"Cleaned up JIF subscription: {key}")
|
|
9702
|
-
|
|
9703
|
-
async def execute(
|
|
9704
|
-
self,
|
|
9705
|
-
node_id: str,
|
|
9706
|
-
node_type: str,
|
|
9707
|
-
config: Dict[str, Any],
|
|
9708
|
-
context: ExecutionContext,
|
|
9709
|
-
**kwargs,
|
|
9710
|
-
) -> Dict[str, Any]:
|
|
9711
|
-
import asyncio
|
|
9712
|
-
from datetime import datetime
|
|
9713
|
-
|
|
9714
|
-
from programgarden_core.exceptions import ConnectionError, ValidationError
|
|
9715
|
-
|
|
9716
|
-
# deep_validate: return a fixture market-status (markets open) so the
|
|
9717
|
-
# flow keeps flowing instead of skipping (no WebSocket/JIF subscription).
|
|
9718
|
-
if context.is_deep_validate:
|
|
9719
|
-
from programgarden import deep_fixtures as _df
|
|
9720
|
-
config = evaluate_all_bindings(config, context, node_id)
|
|
9721
|
-
fixture = _df.market_status_fixture(config)
|
|
9722
|
-
override = context.get_deep_fixture(node_id, node_type)
|
|
9723
|
-
return _df.apply_override(fixture, override)
|
|
9724
|
-
|
|
9725
|
-
# dry_run skip — WebSocket 불가
|
|
9726
|
-
if context.is_dry_run:
|
|
9727
|
-
context.log(
|
|
9728
|
-
"warning",
|
|
9729
|
-
f"[dry_run] MarketStatusNode skipped (realtime WebSocket disabled)",
|
|
9730
|
-
node_id,
|
|
9731
|
-
)
|
|
9732
|
-
return {"status": "skipped_dry_run", "dry_run": True}
|
|
9733
|
-
|
|
9734
|
-
# 1. broker_connection 획득
|
|
9735
|
-
# MarketStatusNode 는 product_scope=ALL 이므로 auto-injection 에서 제외.
|
|
9736
|
-
# 여기서 직접 find_parent_output(...) 로 아무 BrokerNode 나 수용.
|
|
9737
|
-
broker_connection = config.get("connection")
|
|
9738
|
-
if not broker_connection:
|
|
9739
|
-
for broker_type in (
|
|
9740
|
-
"OverseasStockBrokerNode",
|
|
9741
|
-
"KoreaStockBrokerNode",
|
|
9742
|
-
"OverseasFuturesBrokerNode",
|
|
9743
|
-
):
|
|
9744
|
-
candidates = context.find_parent_outputs(node_id, broker_type)
|
|
9745
|
-
if candidates:
|
|
9746
|
-
_bnode_id, _dist, outputs = candidates[0]
|
|
9747
|
-
broker_connection = outputs.get("connection")
|
|
9748
|
-
if broker_connection:
|
|
9749
|
-
context.log(
|
|
9750
|
-
"debug",
|
|
9751
|
-
f"MarketStatusNode: broker_connection resolved from "
|
|
9752
|
-
f"{broker_type}/{_bnode_id} (distance={_dist})",
|
|
9753
|
-
node_id,
|
|
9754
|
-
)
|
|
9755
|
-
break
|
|
9756
|
-
|
|
9757
|
-
if not broker_connection:
|
|
9758
|
-
raise ConnectionError(
|
|
9759
|
-
"MarketStatusNode: BrokerNode 연결이 필요합니다. "
|
|
9760
|
-
"OverseasStockBrokerNode / OverseasFuturesBrokerNode / "
|
|
9761
|
-
"KoreaStockBrokerNode 중 아무거나 상위에 배치하세요."
|
|
9762
|
-
)
|
|
9763
|
-
|
|
9764
|
-
# 2. LS 로그인 (broker 와 동일 instance 재사용)
|
|
9765
|
-
# 🔐 자격증명은 시크릿 저장소에서 — `connection` 출력에는 더 이상 싣지 않는다.
|
|
9766
|
-
appkey, appsecret, paper_trading = _resolve_broker_credentials(broker_connection, context)
|
|
9767
|
-
paper_trading = bool(paper_trading)
|
|
9768
|
-
broker_product = broker_connection.get("product", "overseas_stock")
|
|
9769
|
-
|
|
9770
|
-
ls, success, error = ensure_ls_login(
|
|
9771
|
-
appkey, appsecret, paper_trading, context, node_id, broker_product,
|
|
9772
|
-
caller_name="MarketStatusNode",
|
|
9773
|
-
)
|
|
9774
|
-
if not success:
|
|
9775
|
-
raise ConnectionError(f"MarketStatusNode LS login failed: {error}")
|
|
9776
|
-
|
|
9777
|
-
# 3. Common.real() — broker WSS 와 독립된 공용 세션
|
|
9778
|
-
from programgarden_finance.ls.common import Common
|
|
9779
|
-
|
|
9780
|
-
common = Common(token_manager=ls.token_manager)
|
|
9781
|
-
real = common.real()
|
|
9782
|
-
await real.connect()
|
|
9783
|
-
context.log(
|
|
9784
|
-
"info",
|
|
9785
|
-
f"JIF WebSocket connected via Common.real (paper_trading={paper_trading})",
|
|
9786
|
-
node_id,
|
|
9787
|
-
)
|
|
9788
|
-
|
|
9789
|
-
# 4. 구독 설정
|
|
9790
|
-
markets_filter = config.get("markets", []) or []
|
|
9791
|
-
stay_connected = bool(config.get("stay_connected", True))
|
|
9792
|
-
include_extended = bool(config.get("include_extended_hours", False))
|
|
9793
|
-
trigger_nodes = config.get("_trigger_on_update_nodes", [])
|
|
9794
|
-
|
|
9795
|
-
jif = real.JIF()
|
|
9796
|
-
loop = asyncio.get_running_loop()
|
|
9797
|
-
|
|
9798
|
-
# Track subscription for cleanup (registered even for stay_connected=False
|
|
9799
|
-
# so that an error between subscribe and unsubscribe still gets cleaned up)
|
|
9800
|
-
sub_key = f"{context.job_id}::{node_id}"
|
|
9801
|
-
MarketStatusNodeExecutor._active_subscriptions[sub_key] = {
|
|
9802
|
-
"jif": jif,
|
|
9803
|
-
"real": real,
|
|
9804
|
-
"stay_connected": stay_connected,
|
|
9805
|
-
}
|
|
9806
|
-
|
|
9807
|
-
def _is_open(market: str) -> bool:
|
|
9808
|
-
flag = context.is_market_open(market, include_extended=include_extended)
|
|
9809
|
-
return bool(flag) if flag is not None else False
|
|
9810
|
-
|
|
9811
|
-
def _compute_conv_booleans() -> Dict[str, bool]:
|
|
9812
|
-
conv: Dict[str, bool] = {}
|
|
9813
|
-
for market, port_name in self._SOLO_MARKET_PORTS:
|
|
9814
|
-
conv[port_name] = _is_open(market)
|
|
9815
|
-
for port_name, am_key, pm_key in self._COMBO_MARKET_PORTS:
|
|
9816
|
-
conv[port_name] = bool(_is_open(am_key) or _is_open(pm_key))
|
|
9817
|
-
return conv
|
|
9818
|
-
|
|
9819
|
-
def on_jif_message(resp):
|
|
9820
|
-
"""JIFRealResponse 수신 콜백 — cache/output/notification 일괄 갱신."""
|
|
9821
|
-
|
|
9822
|
-
try:
|
|
9823
|
-
if context.is_shutdown:
|
|
9824
|
-
return
|
|
9825
|
-
body = getattr(resp, "body", None)
|
|
9826
|
-
if body is None:
|
|
9827
|
-
# ACK payload (rsp_cd only). Ignore.
|
|
9828
|
-
return
|
|
9829
|
-
|
|
9830
|
-
jangubun = str(getattr(body, "jangubun", "") or "")
|
|
9831
|
-
jstatus = str(getattr(body, "jstatus", "") or "")
|
|
9832
|
-
if not jangubun:
|
|
9833
|
-
return
|
|
9834
|
-
|
|
9835
|
-
# Canonical market key + open-state derivations (core 레이어)
|
|
9836
|
-
from programgarden_core.nodes.market_status import (
|
|
9837
|
-
JANGUBUN_TO_MARKET,
|
|
9838
|
-
is_extended_open,
|
|
9839
|
-
is_regular_open,
|
|
9840
|
-
)
|
|
9841
|
-
from programgarden_finance.ls.common.real.JIF.constants import (
|
|
9842
|
-
resolve_jstatus,
|
|
9843
|
-
)
|
|
9844
|
-
|
|
9845
|
-
market = JANGUBUN_TO_MARKET.get(jangubun, jangubun)
|
|
9846
|
-
|
|
9847
|
-
# markets 필터 (설정됐으면 구독 자체가 서버측 제약은 아니므로
|
|
9848
|
-
# 여기서 클라이언트 필터링 적용)
|
|
9849
|
-
if markets_filter and market not in markets_filter:
|
|
9850
|
-
return
|
|
9851
|
-
|
|
9852
|
-
status_info = resolve_jstatus(jstatus)
|
|
9853
|
-
reg_open = is_regular_open(jstatus)
|
|
9854
|
-
ext_open = is_extended_open(jstatus)
|
|
9855
|
-
|
|
9856
|
-
new_status = {
|
|
9857
|
-
"market": market,
|
|
9858
|
-
"jangubun": jangubun,
|
|
9859
|
-
"jstatus": jstatus,
|
|
9860
|
-
"jstatus_label": status_info["label"],
|
|
9861
|
-
"is_regular_open": reg_open,
|
|
9862
|
-
"is_extended_open": ext_open,
|
|
9863
|
-
"is_open": ext_open if include_extended else reg_open,
|
|
9864
|
-
"updated_at": datetime.now().isoformat(),
|
|
9865
|
-
}
|
|
9866
|
-
|
|
9867
|
-
# 이전 상태 조회 (transition 감지용)
|
|
9868
|
-
prev_status = context.get_market_status(market)
|
|
9869
|
-
prev_jstatus = prev_status.get("jstatus") if prev_status else None
|
|
9870
|
-
prev_label = prev_status.get("jstatus_label") if prev_status else None
|
|
9871
|
-
is_transition = prev_jstatus is not None and prev_jstatus != jstatus
|
|
9872
|
-
|
|
9873
|
-
# Cache update
|
|
9874
|
-
context.update_market_status(market, new_status)
|
|
9875
|
-
|
|
9876
|
-
# Output update — 바인딩 리졸버가 이 값을 읽음
|
|
9877
|
-
all_statuses = list(context.get_all_market_statuses().values())
|
|
9878
|
-
outputs = {
|
|
9879
|
-
"statuses": all_statuses,
|
|
9880
|
-
"event": {
|
|
9881
|
-
"market": market,
|
|
9882
|
-
"jstatus": jstatus,
|
|
9883
|
-
"jstatus_label": status_info["label"],
|
|
9884
|
-
"prev_jstatus": prev_jstatus,
|
|
9885
|
-
"prev_jstatus_label": prev_label,
|
|
9886
|
-
"transitioned_at": new_status["updated_at"],
|
|
9887
|
-
},
|
|
9888
|
-
**_compute_conv_booleans(),
|
|
9889
|
-
}
|
|
9890
|
-
|
|
9891
|
-
for port_name, port_value in outputs.items():
|
|
9892
|
-
context.set_output(node_id, port_name, port_value)
|
|
9893
|
-
|
|
9894
|
-
# SSE broadcast
|
|
9895
|
-
asyncio.run_coroutine_threadsafe(
|
|
9896
|
-
context.notify_output_update(
|
|
9897
|
-
node_id=node_id,
|
|
9898
|
-
node_type="MarketStatusNode",
|
|
9899
|
-
outputs=outputs,
|
|
9900
|
-
),
|
|
9901
|
-
loop,
|
|
9902
|
-
)
|
|
9903
|
-
|
|
9904
|
-
# 전이 notification (prev != new)
|
|
9905
|
-
if is_transition:
|
|
9906
|
-
asyncio.run_coroutine_threadsafe(
|
|
9907
|
-
context.send_notification(
|
|
9908
|
-
category=NotificationCategory.SCHEDULE_STARTED,
|
|
9909
|
-
severity=NotificationSeverity.INFO,
|
|
9910
|
-
title=f"Market status change: {market}",
|
|
9911
|
-
message=(
|
|
9912
|
-
f"{market}: {prev_label or prev_jstatus} → "
|
|
9913
|
-
f"{status_info['label']} "
|
|
9914
|
-
f"(jstatus {prev_jstatus} → {jstatus})"
|
|
9915
|
-
),
|
|
9916
|
-
node_id=node_id,
|
|
9917
|
-
node_type="MarketStatusNode",
|
|
9918
|
-
data={
|
|
9919
|
-
"market": market,
|
|
9920
|
-
"jangubun": jangubun,
|
|
9921
|
-
"prev_jstatus": prev_jstatus,
|
|
9922
|
-
"jstatus": jstatus,
|
|
9923
|
-
"jstatus_label": status_info["label"],
|
|
9924
|
-
"is_regular_open": reg_open,
|
|
9925
|
-
"is_extended_open": ext_open,
|
|
9926
|
-
},
|
|
9927
|
-
),
|
|
9928
|
-
loop,
|
|
9929
|
-
)
|
|
9930
|
-
|
|
9931
|
-
# Trigger downstream nodes
|
|
9932
|
-
if trigger_nodes:
|
|
9933
|
-
asyncio.run_coroutine_threadsafe(
|
|
9934
|
-
context.emit_event(
|
|
9935
|
-
event_type="market_status",
|
|
9936
|
-
source_node_id=node_id,
|
|
9937
|
-
data=outputs,
|
|
9938
|
-
trigger_nodes=trigger_nodes,
|
|
9939
|
-
),
|
|
9940
|
-
loop,
|
|
9941
|
-
)
|
|
9942
|
-
except Exception as e:
|
|
9943
|
-
context.log("warning", f"JIF message parse error: {e}", node_id)
|
|
9944
|
-
|
|
9945
|
-
jif.on_jif_message(on_jif_message)
|
|
9946
|
-
context.log(
|
|
9947
|
-
"info",
|
|
9948
|
-
f"JIF subscription started "
|
|
9949
|
-
f"(markets={markets_filter or 'all 12'}, "
|
|
9950
|
-
f"stay_connected={stay_connected}, "
|
|
9951
|
-
f"include_extended_hours={include_extended})",
|
|
9952
|
-
node_id,
|
|
9953
|
-
)
|
|
9954
|
-
|
|
9955
|
-
# One-shot 모드: 5초 대기 → 수신된 이벤트 반영 후 해제
|
|
9956
|
-
if not stay_connected:
|
|
9957
|
-
wait_timeout = 5.0
|
|
9958
|
-
check_interval = 0.1
|
|
9959
|
-
deadline = loop.time() + wait_timeout
|
|
9960
|
-
while loop.time() < deadline:
|
|
9961
|
-
if context.get_all_market_statuses():
|
|
9962
|
-
break
|
|
9963
|
-
await asyncio.sleep(check_interval)
|
|
9964
|
-
|
|
9965
|
-
try:
|
|
9966
|
-
jif.on_remove_jif_message()
|
|
9967
|
-
except Exception:
|
|
9968
|
-
pass
|
|
9969
|
-
MarketStatusNodeExecutor._active_subscriptions.pop(sub_key, None)
|
|
9970
|
-
try:
|
|
9971
|
-
await real.close()
|
|
9972
|
-
except Exception:
|
|
9973
|
-
pass
|
|
9974
|
-
|
|
9975
|
-
# 초기/최종 output (stay_connected=True 에서는 현재까지 수신된 snapshot,
|
|
9976
|
-
# False 에서는 해제 직전 snapshot 을 그대로 반환)
|
|
9977
|
-
initial_statuses = list(context.get_all_market_statuses().values())
|
|
9978
|
-
initial_outputs: Dict[str, Any] = {
|
|
9979
|
-
"statuses": initial_statuses,
|
|
9980
|
-
"event": None,
|
|
9981
|
-
**_compute_conv_booleans(),
|
|
9982
|
-
}
|
|
9983
|
-
for port_name, port_value in initial_outputs.items():
|
|
9984
|
-
context.set_output(node_id, port_name, port_value)
|
|
9985
|
-
|
|
9986
|
-
return initial_outputs
|
|
9987
|
-
|
|
9988
9642
|
|
|
9989
9643
|
class DisplayNodeExecutor(NodeExecutorBase):
|
|
9990
9644
|
"""
|
|
@@ -12574,7 +12228,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12574
12228
|
"interval": config.get("interval", "1d"),
|
|
12575
12229
|
}
|
|
12576
12230
|
|
|
12577
|
-
# 기간 설정 ({{
|
|
12231
|
+
# 기간 설정 ({{ date.today(format='yyyymmdd') }}, {{ date.ago(100, format='yyyymmdd') }} 바인딩 사용)
|
|
12578
12232
|
start_date = config.get("start_date", "")
|
|
12579
12233
|
end_date = config.get("end_date", "")
|
|
12580
12234
|
interval = config.get("interval", "1d") # 1d, 1w, 1m, 1min 등
|
|
@@ -20670,10 +20324,6 @@ class WorkflowExecutor:
|
|
|
20670
20324
|
# Data nodes
|
|
20671
20325
|
"SQLiteNode": SQLiteNodeExecutor(),
|
|
20672
20326
|
"CodeNode": CodeNodeExecutor(), # custom Python code (subprocess-isolated)
|
|
20673
|
-
# External market data nodes (credential 불필요, 외부 API)
|
|
20674
|
-
"CurrencyRateNode": GenericNodeExecutor(),
|
|
20675
|
-
# Market Status (JIF, broker credential type 무관)
|
|
20676
|
-
"MarketStatusNode": MarketStatusNodeExecutor(),
|
|
20677
20327
|
# AI nodes
|
|
20678
20328
|
"LLMModelNode": LLMModelNodeExecutor(),
|
|
20679
20329
|
"AIAgentNode": AIAgentNodeExecutor(),
|
|
@@ -21775,8 +21425,6 @@ class WorkflowJob:
|
|
|
21775
21425
|
finally:
|
|
21776
21426
|
# Cleanup broker fill subscriptions (SDK 콜백 해제)
|
|
21777
21427
|
await self._cleanup_broker_fill_subscriptions()
|
|
21778
|
-
# Cleanup MarketStatusNode JIF subscriptions
|
|
21779
|
-
await self._cleanup_jif_subscriptions()
|
|
21780
21428
|
# Cleanup persistent nodes (shutdown flag 설정 + 구독 해제 + WebSocket close)
|
|
21781
21429
|
await self.context.cleanup_persistent_nodes()
|
|
21782
21430
|
# Cleanup listeners
|
|
@@ -21932,7 +21580,7 @@ class WorkflowJob:
|
|
|
21932
21580
|
config["_source_node_id"] = edge.from_node_id
|
|
21933
21581
|
break # 첫 번째 상위 노드만 사용
|
|
21934
21582
|
|
|
21935
|
-
# Resolve expressions in config ({{ input.xxx }}, {{ nodeId.port }})
|
|
21583
|
+
# Resolve expressions in config ({{ input.xxx }}, {{ nodes.nodeId.port }})
|
|
21936
21584
|
config = self._resolve_config_expressions(config, node_id)
|
|
21937
21585
|
|
|
21938
21586
|
# Auto-inject connection from matching BrokerNode (Phase 5)
|
|
@@ -22349,7 +21997,7 @@ class WorkflowJob:
|
|
|
22349
21997
|
# 브로커/외부 API 제한**을 모델링한 것이라, 호출 자체가 없는 dry_run 모의 노드에는
|
|
22350
21998
|
# 순수 낭비다 — 실측: 모의 주문 16건 × 5초 = 75초가 60초 빌드 타임아웃을 넘겼다
|
|
22351
21999
|
# (prod 대화 f2eed93c, 2026-08-28). dry_run 에서도 **실제 호출하는** 노드
|
|
22352
|
-
# (HTTPRequestNode 1
|
|
22000
|
+
# (HTTPRequestNode 1초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
|
|
22353
22001
|
DRY_RUN_SIMULATED_NODE_TYPES = frozenset({
|
|
22354
22002
|
# 주문 — NewOrderNodeExecutor / ModifyOrderNodeExecutor / CancelOrderNodeExecutor
|
|
22355
22003
|
"OverseasStockNewOrderNode", "OverseasStockModifyOrderNode", "OverseasStockCancelOrderNode",
|
|
@@ -23653,7 +23301,8 @@ class WorkflowJob:
|
|
|
23653
23301
|
|
|
23654
23302
|
지원 표현식:
|
|
23655
23303
|
- {{ input.xxx }}: 워크플로우 inputs 파라미터
|
|
23656
|
-
- {{ nodeId.port }}: 이전 노드 출력값
|
|
23304
|
+
- {{ nodes.nodeId.port }}: 이전 노드 출력값 (`nodes.` 접두사 필수 — 접두사 없는
|
|
23305
|
+
`{{ nodeId.port }}` 는 정의되지 않은 변수 오류)
|
|
23657
23306
|
- {{ context.xxx }}: 실행 컨텍스트 값
|
|
23658
23307
|
|
|
23659
23308
|
Note: items 키는 제외 (ConditionNode의 _process_items_with_extract에서 별도 처리).
|
|
@@ -24280,14 +23929,6 @@ class WorkflowJob:
|
|
|
24280
23929
|
except Exception as e:
|
|
24281
23930
|
logger.warning(f"Failed to cleanup broker fill subscriptions: {e}")
|
|
24282
23931
|
|
|
24283
|
-
async def _cleanup_jif_subscriptions(self) -> None:
|
|
24284
|
-
"""MarketStatusNode의 JIF 구독 SDK 콜백 정리."""
|
|
24285
|
-
try:
|
|
24286
|
-
ms_executor = MarketStatusNodeExecutor()
|
|
24287
|
-
await ms_executor.cleanup_jif_subscriptions(self.job_id)
|
|
24288
|
-
except Exception as e:
|
|
24289
|
-
logger.warning(f"Failed to cleanup JIF subscriptions: {e}")
|
|
24290
|
-
|
|
24291
23932
|
async def stop(self) -> None:
|
|
24292
23933
|
"""Stop execution gracefully"""
|
|
24293
23934
|
logger.info(f"Stopping job {self.job_id}")
|
|
@@ -24318,7 +23959,6 @@ class WorkflowJob:
|
|
|
24318
23959
|
# 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
|
|
24319
23960
|
# 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
|
|
24320
23961
|
await self._cleanup_broker_fill_subscriptions()
|
|
24321
|
-
await self._cleanup_jif_subscriptions()
|
|
24322
23962
|
await self.context.cleanup_persistent_nodes()
|
|
24323
23963
|
await self.context.cleanup_flow_end_nodes()
|
|
24324
23964
|
|
|
@@ -24357,7 +23997,6 @@ class WorkflowJob:
|
|
|
24357
23997
|
|
|
24358
23998
|
# Cleanup
|
|
24359
23999
|
await self._cleanup_broker_fill_subscriptions()
|
|
24360
|
-
await self._cleanup_jif_subscriptions()
|
|
24361
24000
|
await self.context.cleanup_persistent_nodes()
|
|
24362
24001
|
await self.context.cleanup_flow_end_nodes()
|
|
24363
24002
|
|
|
@@ -24422,7 +24061,6 @@ class WorkflowJob:
|
|
|
24422
24061
|
|
|
24423
24062
|
# 모든 리소스 정리
|
|
24424
24063
|
await self._cleanup_broker_fill_subscriptions()
|
|
24425
|
-
await self._cleanup_jif_subscriptions()
|
|
24426
24064
|
await self.context.cleanup_persistent_nodes()
|
|
24427
24065
|
await self.context.cleanup_flow_end_nodes()
|
|
24428
24066
|
|
|
@@ -128,7 +128,13 @@ async def replay_events(job, runner, fixture, outcome):
|
|
|
128
128
|
if now != expected:
|
|
129
129
|
raise ContractViolation(source_id, "Recorded tick is not the next configured cron instant")
|
|
130
130
|
count = schedule_counts.get(source_id, 0) + 1
|
|
131
|
-
|
|
131
|
+
# count / max_duration_hours are optional (None/absent = unbounded,
|
|
132
|
+
# run until the workflow is stopped). Only enforce a bound that was set.
|
|
133
|
+
cfg_count = config.get("count")
|
|
134
|
+
cfg_max_dur = config.get("max_duration_hours")
|
|
135
|
+
if (cfg_count is not None and count > cfg_count) or (
|
|
136
|
+
cfg_max_dur is not None and (now - origin).total_seconds() > cfg_max_dur * 3600
|
|
137
|
+
):
|
|
132
138
|
raise ContractViolation(source_id, "Recorded ticks exceed schedule safety limits")
|
|
133
139
|
schedule_instants[source_id], schedule_counts[source_id] = now, count
|
|
134
140
|
else:
|
|
@@ -111,8 +111,13 @@ def request_identity(node_type, config):
|
|
|
111
111
|
raise ContractViolation("request", "Invalid resolved recording request", "REPLAY_FIXTURE_INVALID") from exc
|
|
112
112
|
# Reject unknown parameters instead of letting BaseModel's extra policy
|
|
113
113
|
# silently remove a field the live executor may consume.
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
unknown = sorted(set(config) - set(node_class.model_fields) - {"connection"} - _SCHEDULER_FIELDS - _PRESENTATION_FIELDS)
|
|
115
|
+
if unknown:
|
|
116
|
+
# Name the keys: a builder that wrote `fields: ["price"]` on a market-data node
|
|
117
|
+
# (dev 5d9961de, 2026-09-25) retried the same config twice on the bare message.
|
|
118
|
+
accepted = sorted(set(node_class.model_fields) - _PRESENTATION_FIELDS - {"id", "type", "name", "description", "position", "category"})
|
|
119
|
+
raise ContractViolation("request", "Unknown recording request fields: " + ", ".join(unknown)
|
|
120
|
+
+ " (" + node_type + " accepts: " + ", ".join(accepted) + ")", "REPLAY_FIXTURE_INVALID")
|
|
116
121
|
result = node.model_dump(mode="json", exclude=_PRESENTATION_FIELDS | _SCHEDULER_FIELDS | {"connection"})
|
|
117
122
|
connection = config.get("connection")
|
|
118
123
|
if connection is not None:
|
|
@@ -349,8 +349,9 @@ def _time_rules(node_type: str, role: str) -> List[Dict[str, Any]]:
|
|
|
349
349
|
{"id": "timezone_iana", "field": "timezone",
|
|
350
350
|
"default": "America/New_York", "invalid": "replay_rejects"},
|
|
351
351
|
{"id": "disabled_emits_nothing", "field": "enabled"},
|
|
352
|
-
{"id": "limits", "
|
|
353
|
-
"
|
|
352
|
+
{"id": "limits", "optional": True, "absent_means": "unbounded",
|
|
353
|
+
"count": {"default": None, "min": 1},
|
|
354
|
+
"max_duration_hours": {"default": None, "gt": 0}, "pin": "range_not_const"},
|
|
354
355
|
]
|
|
355
356
|
if node_type == "SessionGateNode":
|
|
356
357
|
return [{"id": "evaluate_at_fixture_instant"}] # validation_replay.py:276-283
|
|
@@ -607,8 +608,10 @@ def _schedule_time_lines(lines: List[str]) -> None:
|
|
|
607
608
|
lines.append("A recorded tick must equal the cron's next firing instant after "
|
|
608
609
|
"the prior frame in this timezone: a whole minute, at least one "
|
|
609
610
|
"minute later.")
|
|
610
|
-
lines.append("count and max_duration_hours are safety limits
|
|
611
|
-
"
|
|
611
|
+
lines.append("count and max_duration_hours are optional safety limits; omit both "
|
|
612
|
+
"and the schedule runs until the workflow is stopped. When provided, "
|
|
613
|
+
"count must be >= 1 and max_duration_hours > 0, and a contract bounds "
|
|
614
|
+
"them with a range, never a const.")
|
|
612
615
|
lines.append("Set the cron time in the target market's timezone. Market "
|
|
613
616
|
"operating hours by product:")
|
|
614
617
|
pe = product_execution()
|
|
@@ -4,13 +4,12 @@ These records are scenario assumptions, not evidence of provider availability.
|
|
|
4
4
|
The independent suite owns the inputs. No adapter reads a live file or transport.
|
|
5
5
|
"""
|
|
6
6
|
from copy import deepcopy
|
|
7
|
-
from pathlib import PurePosixPath
|
|
8
7
|
|
|
9
8
|
from programgarden.replay_contracts import ContractViolation, check_contract
|
|
10
9
|
from programgarden.replay_external import external_record
|
|
11
10
|
from programgarden.replay_triggers import fixture_instant
|
|
12
11
|
|
|
13
|
-
SOURCE_NODES = frozenset({"
|
|
12
|
+
SOURCE_NODES = frozenset({"MarketUniverseNode",
|
|
14
13
|
"FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode"})
|
|
15
14
|
|
|
16
15
|
|
|
@@ -30,16 +29,6 @@ def source_contract_catalog():
|
|
|
30
29
|
"type": "array", "items": {"type": "object", "properties": {
|
|
31
30
|
"google": {"type": "string"}, "currency": {"type": "string"}}}}}}
|
|
32
31
|
return {
|
|
33
|
-
"FearGreedIndexNode": {"source_schema": {"type": "object", "required": ["fear_and_greed"],
|
|
34
|
-
"properties": {"fear_and_greed": {"type": "object", "required": ["score"], "properties": {
|
|
35
|
-
"score": {"type": "number", "minimum": 0, "maximum": 100},
|
|
36
|
-
"previous_close": {"type": "number"}}}}},
|
|
37
|
-
"notes": "Native parser maps score to value/label and reads previous_close. Synthetic inputs only."},
|
|
38
|
-
"FileReaderNode": {"source_schema": {"type": "object", "properties": {"files": {
|
|
39
|
-
"type": "object", "additionalProperties": {"type": "string"}}}},
|
|
40
|
-
"notes": "files maps each requested relative upload path to base64 encoded bytes. The native "
|
|
41
|
-
"format parser reads those bytes; do not supply parsed data. Inline file_data uses "
|
|
42
|
-
"native configuration. Absolute paths and parent traversal are forbidden."},
|
|
43
32
|
"MarketUniverseNode": {"source_schema": {"type": "object", "required": ["index", "stocks"],
|
|
44
33
|
"properties": {"index": {"type": "string"}, "stocks": {"type": "array", "items": stock}}},
|
|
45
34
|
"index_mapping": dict(MarketUniverseNodeExecutor.INDEX_MAPPING),
|
|
@@ -92,33 +81,6 @@ async def execute_source(node, config, fixture, context):
|
|
|
92
81
|
source = deepcopy(record["source"])
|
|
93
82
|
if not isinstance(source, dict):
|
|
94
83
|
raise ContractViolation(node.id, "Source envelope must be an object")
|
|
95
|
-
if node.type == "FearGreedIndexNode":
|
|
96
|
-
check_contract(source, {"type": "object", "required": ["fear_and_greed"], "properties": {
|
|
97
|
-
"fear_and_greed": {"type": "object", "required": ["score"], "properties": {
|
|
98
|
-
"score": {"type": "number", "minimum": 0, "maximum": 100}}}}}, node.id)
|
|
99
|
-
return node.parse_response(source)
|
|
100
|
-
if node.type == "FileReaderNode":
|
|
101
|
-
# Materialize the exact recorded bytes through the native base64 path.
|
|
102
|
-
# Preserve format/name selection; never read /app/data or a host path.
|
|
103
|
-
paths, data, names = node._normalize_inputs()
|
|
104
|
-
files = source.get("files", {})
|
|
105
|
-
for index, path in enumerate(paths):
|
|
106
|
-
if not path:
|
|
107
|
-
continue
|
|
108
|
-
parsed = PurePosixPath(path)
|
|
109
|
-
if parsed.is_absolute() or ".." in parsed.parts:
|
|
110
|
-
raise ContractViolation(node.id, "Replay file paths must stay relative to the upload directory")
|
|
111
|
-
if path not in files or not isinstance(files[path], str):
|
|
112
|
-
raise ContractViolation(node.id, "Requested file has no recorded bytes", "REPLAY_FIXTURE_REQUIRED")
|
|
113
|
-
while len(data) <= index:
|
|
114
|
-
data.append("")
|
|
115
|
-
while len(names) <= index:
|
|
116
|
-
names.append("")
|
|
117
|
-
data[index] = files[path]
|
|
118
|
-
names[index] = names[index] or PurePosixPath(path).name
|
|
119
|
-
local = node.model_copy(update={"file_paths": [], "file_path": None,
|
|
120
|
-
"file_data_list": data, "file_data": None, "file_names": names, "file_name": None})
|
|
121
|
-
return await local.execute(context)
|
|
122
84
|
if node.type == "MarketUniverseNode":
|
|
123
85
|
from programgarden.executor import MarketUniverseNodeExecutor
|
|
124
86
|
executor = MarketUniverseNodeExecutor()
|
|
@@ -49,8 +49,13 @@ async def schedule_startup(node, config, context):
|
|
|
49
49
|
ZoneInfo(node.timezone)
|
|
50
50
|
except (ValueError, KeyError):
|
|
51
51
|
raise ContractViolation(node.id, "Invalid schedule timezone") from None
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
# count / max_duration_hours are optional (None = unbounded, run until the
|
|
53
|
+
# workflow is stopped). Only a provided value is bounded (count >= 1,
|
|
54
|
+
# max_duration_hours > 0).
|
|
55
|
+
if (node.count is not None and node.count < 1) or (
|
|
56
|
+
node.max_duration_hours is not None and node.max_duration_hours <= 0
|
|
57
|
+
):
|
|
58
|
+
raise ContractViolation(node.id, "Schedule safety limits must be positive when set")
|
|
54
59
|
proxy = _StartupContext(context)
|
|
55
60
|
try:
|
|
56
61
|
return await ScheduleNodeExecutor().execute(node.id, node.type, config, proxy)
|
|
@@ -39,7 +39,7 @@ FIXTURE_NODES = frozenset({
|
|
|
39
39
|
for kind in ("Broker", "Account", "RealAccount", "MarketData", "HistoricalData",
|
|
40
40
|
"Fundamental", "SymbolQuery", "OpenOrders", "RealMarketData", "RealOrderEvent")
|
|
41
41
|
if not (product == "OverseasFutures" and kind == "Fundamental")
|
|
42
|
-
}) | {"HTTPRequestNode", "LLMModelNode", "AIAgentNode", "
|
|
42
|
+
}) | {"HTTPRequestNode", "LLMModelNode", "AIAgentNode", "TelegramNode"}
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def content_hash(value: Any) -> str:
|
|
@@ -251,6 +251,19 @@ class ReplayExecutor(WorkflowExecutor):
|
|
|
251
251
|
raise ContractViolation(node_id,"Completion evidence requires an existing request and broker observation node")
|
|
252
252
|
self.orders.apply_events(record["order_events"], node_type)
|
|
253
253
|
output = deepcopy(record["output"])
|
|
254
|
+
if isinstance(output, dict):
|
|
255
|
+
# The live executor returns its ports in declaration order and the
|
|
256
|
+
# auto-iteration fallback source is a node's FIRST output; a recording
|
|
257
|
+
# written as {"count": 1, "open_orders": [...]} must not make replay pick
|
|
258
|
+
# `count` where live picks the list (dev 5d9961de, 2026-09-25).
|
|
259
|
+
from programgarden_core import NodeTypeRegistry
|
|
260
|
+
try:
|
|
261
|
+
declared = [port["name"] for port in NodeTypeRegistry().get_schema(node_type).outputs]
|
|
262
|
+
except Exception: # noqa: BLE001 — unknown schema keeps the recorded order
|
|
263
|
+
declared = []
|
|
264
|
+
ordered = {name: output[name] for name in declared if name in output}
|
|
265
|
+
ordered.update({k: v for k, v in output.items() if k not in ordered})
|
|
266
|
+
output = ordered
|
|
254
267
|
check_contract(output, record["contract"], f"{node_id}.output")
|
|
255
268
|
if node_type.endswith("OpenOrdersNode"):
|
|
256
269
|
# The snapshot SHAPE (unique non-empty order_ids, count == rows)
|
|
@@ -5,7 +5,7 @@ authors = [
|
|
|
5
5
|
homepage = "https://programgarden.com"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
name = "programgarden"
|
|
8
|
-
version = "2.
|
|
8
|
+
version = "2.4.0"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -36,9 +36,9 @@ litellm = ">=1.40.0"
|
|
|
36
36
|
# 로 통째 실패한다(1.35.1 의 조용한 TypeError 보다 강한 모듈 로드 실패).
|
|
37
37
|
# (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
|
|
38
38
|
# 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
|
|
39
|
-
programgarden-core = "^2.
|
|
39
|
+
programgarden-core = "^2.4.0"
|
|
40
40
|
programgarden-finance = "^2.0.1"
|
|
41
|
-
programgarden-community = "^2.
|
|
41
|
+
programgarden-community = "^2.2.0"
|
|
42
42
|
|
|
43
43
|
[tool.poetry.group.dev.dependencies]
|
|
44
44
|
pytest = "^8.0.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
{programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/position_reconciliation.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden-2.2.0 → programgarden-2.4.0}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|