programgarden 2.1.0__tar.gz → 2.3.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.1.0 → programgarden-2.3.0}/PKG-INFO +3 -3
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/context.py +5 -50
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/deep_fixtures.py +0 -13
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/executor.py +5 -371
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_external.py +7 -2
- programgarden-2.3.0/programgarden/replay_semantics.py +642 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_sources.py +1 -39
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/registry_tools.py +7 -2
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/validation_recommender.py +0 -1
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/validation_replay.py +14 -1
- {programgarden-2.1.0 → programgarden-2.3.0}/pyproject.toml +3 -3
- {programgarden-2.1.0 → programgarden-2.3.0}/README.md +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/binding_validator.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/client.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/code_worker.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/incremental_build.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/node_runner.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_events.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_orders.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_scenarios.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_sqlite.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resolver.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resource/context.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.0}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.1.0 → programgarden-2.3.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.3.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.3.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],
|
|
@@ -9635,356 +9635,6 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
|
|
|
9635
9635
|
context.log("error", f"Korea stock order event error: {e}", node_id)
|
|
9636
9636
|
return {"error": str(e)}
|
|
9637
9637
|
|
|
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
9638
|
|
|
9989
9639
|
class DisplayNodeExecutor(NodeExecutorBase):
|
|
9990
9640
|
"""
|
|
@@ -12574,7 +12224,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12574
12224
|
"interval": config.get("interval", "1d"),
|
|
12575
12225
|
}
|
|
12576
12226
|
|
|
12577
|
-
# 기간 설정 ({{
|
|
12227
|
+
# 기간 설정 ({{ date.today(format='yyyymmdd') }}, {{ date.ago(100, format='yyyymmdd') }} 바인딩 사용)
|
|
12578
12228
|
start_date = config.get("start_date", "")
|
|
12579
12229
|
end_date = config.get("end_date", "")
|
|
12580
12230
|
interval = config.get("interval", "1d") # 1d, 1w, 1m, 1min 등
|
|
@@ -20670,10 +20320,6 @@ class WorkflowExecutor:
|
|
|
20670
20320
|
# Data nodes
|
|
20671
20321
|
"SQLiteNode": SQLiteNodeExecutor(),
|
|
20672
20322
|
"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
20323
|
# AI nodes
|
|
20678
20324
|
"LLMModelNode": LLMModelNodeExecutor(),
|
|
20679
20325
|
"AIAgentNode": AIAgentNodeExecutor(),
|
|
@@ -21775,8 +21421,6 @@ class WorkflowJob:
|
|
|
21775
21421
|
finally:
|
|
21776
21422
|
# Cleanup broker fill subscriptions (SDK 콜백 해제)
|
|
21777
21423
|
await self._cleanup_broker_fill_subscriptions()
|
|
21778
|
-
# Cleanup MarketStatusNode JIF subscriptions
|
|
21779
|
-
await self._cleanup_jif_subscriptions()
|
|
21780
21424
|
# Cleanup persistent nodes (shutdown flag 설정 + 구독 해제 + WebSocket close)
|
|
21781
21425
|
await self.context.cleanup_persistent_nodes()
|
|
21782
21426
|
# Cleanup listeners
|
|
@@ -21932,7 +21576,7 @@ class WorkflowJob:
|
|
|
21932
21576
|
config["_source_node_id"] = edge.from_node_id
|
|
21933
21577
|
break # 첫 번째 상위 노드만 사용
|
|
21934
21578
|
|
|
21935
|
-
# Resolve expressions in config ({{ input.xxx }}, {{ nodeId.port }})
|
|
21579
|
+
# Resolve expressions in config ({{ input.xxx }}, {{ nodes.nodeId.port }})
|
|
21936
21580
|
config = self._resolve_config_expressions(config, node_id)
|
|
21937
21581
|
|
|
21938
21582
|
# Auto-inject connection from matching BrokerNode (Phase 5)
|
|
@@ -22349,7 +21993,7 @@ class WorkflowJob:
|
|
|
22349
21993
|
# 브로커/외부 API 제한**을 모델링한 것이라, 호출 자체가 없는 dry_run 모의 노드에는
|
|
22350
21994
|
# 순수 낭비다 — 실측: 모의 주문 16건 × 5초 = 75초가 60초 빌드 타임아웃을 넘겼다
|
|
22351
21995
|
# (prod 대화 f2eed93c, 2026-08-28). dry_run 에서도 **실제 호출하는** 노드
|
|
22352
|
-
# (HTTPRequestNode 1
|
|
21996
|
+
# (HTTPRequestNode 1초)는 이 집합에 넣지 말 것 — 간격을 지켜야 한다.
|
|
22353
21997
|
DRY_RUN_SIMULATED_NODE_TYPES = frozenset({
|
|
22354
21998
|
# 주문 — NewOrderNodeExecutor / ModifyOrderNodeExecutor / CancelOrderNodeExecutor
|
|
22355
21999
|
"OverseasStockNewOrderNode", "OverseasStockModifyOrderNode", "OverseasStockCancelOrderNode",
|
|
@@ -23653,7 +23297,8 @@ class WorkflowJob:
|
|
|
23653
23297
|
|
|
23654
23298
|
지원 표현식:
|
|
23655
23299
|
- {{ input.xxx }}: 워크플로우 inputs 파라미터
|
|
23656
|
-
- {{ nodeId.port }}: 이전 노드 출력값
|
|
23300
|
+
- {{ nodes.nodeId.port }}: 이전 노드 출력값 (`nodes.` 접두사 필수 — 접두사 없는
|
|
23301
|
+
`{{ nodeId.port }}` 는 정의되지 않은 변수 오류)
|
|
23657
23302
|
- {{ context.xxx }}: 실행 컨텍스트 값
|
|
23658
23303
|
|
|
23659
23304
|
Note: items 키는 제외 (ConditionNode의 _process_items_with_extract에서 별도 처리).
|
|
@@ -24280,14 +23925,6 @@ class WorkflowJob:
|
|
|
24280
23925
|
except Exception as e:
|
|
24281
23926
|
logger.warning(f"Failed to cleanup broker fill subscriptions: {e}")
|
|
24282
23927
|
|
|
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
23928
|
async def stop(self) -> None:
|
|
24292
23929
|
"""Stop execution gracefully"""
|
|
24293
23930
|
logger.info(f"Stopping job {self.job_id}")
|
|
@@ -24318,7 +23955,6 @@ class WorkflowJob:
|
|
|
24318
23955
|
# 리밋·불필요 호출 유발). cleanup_* 는 끝에서 딕셔너리를 비우므로 _run 의 정상완료
|
|
24319
23956
|
# 경로가 먼저 정리했더라도 여기서 다시 불려도 빈 순회 = no-op(멱등).
|
|
24320
23957
|
await self._cleanup_broker_fill_subscriptions()
|
|
24321
|
-
await self._cleanup_jif_subscriptions()
|
|
24322
23958
|
await self.context.cleanup_persistent_nodes()
|
|
24323
23959
|
await self.context.cleanup_flow_end_nodes()
|
|
24324
23960
|
|
|
@@ -24357,7 +23993,6 @@ class WorkflowJob:
|
|
|
24357
23993
|
|
|
24358
23994
|
# Cleanup
|
|
24359
23995
|
await self._cleanup_broker_fill_subscriptions()
|
|
24360
|
-
await self._cleanup_jif_subscriptions()
|
|
24361
23996
|
await self.context.cleanup_persistent_nodes()
|
|
24362
23997
|
await self.context.cleanup_flow_end_nodes()
|
|
24363
23998
|
|
|
@@ -24422,7 +24057,6 @@ class WorkflowJob:
|
|
|
24422
24057
|
|
|
24423
24058
|
# 모든 리소스 정리
|
|
24424
24059
|
await self._cleanup_broker_fill_subscriptions()
|
|
24425
|
-
await self._cleanup_jif_subscriptions()
|
|
24426
24060
|
await self.context.cleanup_persistent_nodes()
|
|
24427
24061
|
await self.context.cleanup_flow_end_nodes()
|
|
24428
24062
|
|
|
@@ -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:
|