programgarden 2.5.6__tar.gz → 2.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {programgarden-2.5.6 → programgarden-2.6.1}/PKG-INFO +15 -2
- {programgarden-2.5.6 → programgarden-2.6.1}/README.md +13 -0
- programgarden-2.6.1/programgarden/database/sqlite_scope.py +19 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/executor.py +87 -8
- programgarden-2.6.1/programgarden/futures_contract_info.py +91 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_events.py +15 -4
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_scenarios.py +2 -2
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_sources.py +12 -1
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_sqlite.py +2 -1
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resolver.py +24 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/validation_replay.py +9 -2
- {programgarden-2.5.6 → programgarden-2.6.1}/pyproject.toml +2 -2
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/binding_validator.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/client.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/code_worker.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/context.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_snapshot.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/position_reconciliation.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/deep_fixtures.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/incremental_build.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/node_runner.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_external.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_orders.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_semantics.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/context.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/validation_recommender.py +0 -0
- {programgarden-2.5.6 → programgarden-2.6.1}/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.6.1
|
|
4
4
|
Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
|
|
5
5
|
License-Expression: AGPL-3.0-or-later
|
|
6
6
|
Author: 프로그램동산
|
|
@@ -16,7 +16,7 @@ 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
18
|
Requires-Dist: programgarden-community (>=2.3.1,<3.0.0)
|
|
19
|
-
Requires-Dist: programgarden-core (>=2.
|
|
19
|
+
Requires-Dist: programgarden-core (>=2.6.1,<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)
|
|
@@ -29,6 +29,19 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
# ProgramGarden
|
|
31
31
|
|
|
32
|
+
An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
|
|
33
|
+
rejects unsupported intraday periods instead of silently querying daily bars.
|
|
34
|
+
Supported g3204 periods retain their existing requests and output contract.
|
|
35
|
+
|
|
36
|
+
An unreleased [futures detail and durable state candidate](../../docs/futures-contract-details-and-rollover-state.md)
|
|
37
|
+
adds exact o3105 metadata, opt-in execution-scoped SQLite and XHKG calendar
|
|
38
|
+
intersection. Missing evidence remains explicit; this does not authorize orders.
|
|
39
|
+
|
|
40
|
+
An unreleased [offline temporal replay extension](../../docs/offline-temporal-replay.md)
|
|
41
|
+
supports explicit bounded multi-day acceptance without changing the default
|
|
42
|
+
32-event worker/save path. Public package release remains pending; the server
|
|
43
|
+
team has validated a private DEV candidate with the same runtime changes.
|
|
44
|
+
|
|
32
45
|
The REST overseas-stock account output preserves broker-reported `sellable_qty`
|
|
33
46
|
separately from holdings; absent capacity remains null. See
|
|
34
47
|
[the sell-capacity contract](../../docs/overseas-stock-sellable-quantity.md).
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# ProgramGarden
|
|
2
2
|
|
|
3
|
+
An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
|
|
4
|
+
rejects unsupported intraday periods instead of silently querying daily bars.
|
|
5
|
+
Supported g3204 periods retain their existing requests and output contract.
|
|
6
|
+
|
|
7
|
+
An unreleased [futures detail and durable state candidate](../../docs/futures-contract-details-and-rollover-state.md)
|
|
8
|
+
adds exact o3105 metadata, opt-in execution-scoped SQLite and XHKG calendar
|
|
9
|
+
intersection. Missing evidence remains explicit; this does not authorize orders.
|
|
10
|
+
|
|
11
|
+
An unreleased [offline temporal replay extension](../../docs/offline-temporal-replay.md)
|
|
12
|
+
supports explicit bounded multi-day acceptance without changing the default
|
|
13
|
+
32-event worker/save path. Public package release remains pending; the server
|
|
14
|
+
team has validated a private DEV candidate with the same runtime changes.
|
|
15
|
+
|
|
3
16
|
The REST overseas-stock account output preserves broker-reported `sellable_qty`
|
|
4
17
|
separately from holdings; absent capacity remains null. See
|
|
5
18
|
[the sell-capacity contract](../../docs/overseas-stock-sellable-quantity.md).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Opt-in strategy SQLite namespace below the host-provided durable directory."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def scoped_sqlite_filename(config, context):
|
|
7
|
+
name = config.get('db_name', 'default.db')
|
|
8
|
+
scope = config.get('storage_scope', 'shared')
|
|
9
|
+
if scope == 'shared':
|
|
10
|
+
return name
|
|
11
|
+
if scope != 'execution':
|
|
12
|
+
raise ValueError('Unknown SQLite storage scope')
|
|
13
|
+
if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9_-][A-Za-z0-9_.-]{0,119}\.db', name):
|
|
14
|
+
raise ValueError('Execution-scoped SQLite requires a plain .db filename')
|
|
15
|
+
key = context.execution_key or context.workflow_id
|
|
16
|
+
if not isinstance(key, str) or not key.strip():
|
|
17
|
+
raise ValueError('Execution-scoped SQLite requires a stable execution or workflow identity')
|
|
18
|
+
basis = ('execution:' if context.execution_key else 'workflow:') + key
|
|
19
|
+
return 'strategy_' + hashlib.sha256(basis.encode()).hexdigest() + '_' + name
|
|
@@ -6312,6 +6312,49 @@ class AccountNodeExecutor(NodeExecutorBase):
|
|
|
6312
6312
|
return result
|
|
6313
6313
|
|
|
6314
6314
|
|
|
6315
|
+
class FuturesContractInfoNodeExecutor(NodeExecutorBase):
|
|
6316
|
+
"""Read exact contract details independently of quote availability."""
|
|
6317
|
+
|
|
6318
|
+
async def execute(self, node_id, node_type, config, context, **kwargs):
|
|
6319
|
+
from programgarden.futures_contract_info import (
|
|
6320
|
+
FuturesContractEvidenceError, build_contract_info_request, read_contract_info,
|
|
6321
|
+
synthetic_contract_info,
|
|
6322
|
+
)
|
|
6323
|
+
config = evaluate_all_bindings(config, context, node_id)
|
|
6324
|
+
try:
|
|
6325
|
+
request = build_contract_info_request(config)
|
|
6326
|
+
if context.is_deep_validate:
|
|
6327
|
+
from programgarden import deep_fixtures
|
|
6328
|
+
return deep_fixtures.apply_override(
|
|
6329
|
+
synthetic_contract_info(config), context.get_deep_fixture(node_id, node_type),
|
|
6330
|
+
)
|
|
6331
|
+
connection = config.get("connection")
|
|
6332
|
+
if not isinstance(connection, dict) or connection.get("product") != "overseas_futures" or not connection.get("credential_id"):
|
|
6333
|
+
raise FuturesContractEvidenceError("A matching futures broker connection is required")
|
|
6334
|
+
try:
|
|
6335
|
+
credential = exact_futures_credential(connection, context)
|
|
6336
|
+
except ValueError as exc:
|
|
6337
|
+
raise FuturesContractEvidenceError(str(exc)) from exc
|
|
6338
|
+
if not credential or not credential.get("appkey") or not credential.get("appsecret"):
|
|
6339
|
+
raise FuturesContractEvidenceError("The selected futures credential is unavailable")
|
|
6340
|
+
ls, success, _ = ensure_ls_login(
|
|
6341
|
+
credential["appkey"], credential["appsecret"], connection.get("paper_trading", False),
|
|
6342
|
+
context, node_id, product="overseas_futures", caller_name="FuturesContractInfoNode",
|
|
6343
|
+
)
|
|
6344
|
+
if not success:
|
|
6345
|
+
raise FuturesContractEvidenceError("The selected futures broker login failed")
|
|
6346
|
+
response = await asyncio.wait_for(
|
|
6347
|
+
ls.overseas_futureoption().market().o3105(body=request).req_async(), timeout=20,
|
|
6348
|
+
)
|
|
6349
|
+
return read_contract_info(response, config)
|
|
6350
|
+
except (FuturesContractEvidenceError, asyncio.TimeoutError) as exc:
|
|
6351
|
+
reason = str(exc) or "Futures contract detail query timed out"
|
|
6352
|
+
except Exception as exc:
|
|
6353
|
+
reason = f"Futures contract detail query failed ({type(exc).__name__})"
|
|
6354
|
+
context.log("warning", reason, node_id)
|
|
6355
|
+
return {"value": None, "verified": False, "missing_fields": [], "error": reason}
|
|
6356
|
+
|
|
6357
|
+
|
|
6315
6358
|
class FuturesOrderableQuantityNodeExecutor(NodeExecutorBase):
|
|
6316
6359
|
"""Read per-contract capacity; never place an order or replace failed evidence."""
|
|
6317
6360
|
|
|
@@ -8479,7 +8522,19 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
|
|
|
8479
8522
|
"""LS증권 실시간 주문 이벤트 구독"""
|
|
8480
8523
|
|
|
8481
8524
|
# secrets에서 인증 정보 가져오기
|
|
8482
|
-
|
|
8525
|
+
if product == "overseas_futures":
|
|
8526
|
+
try:
|
|
8527
|
+
credential = exact_futures_credential(config.get("connection") or {}, context)
|
|
8528
|
+
except ValueError:
|
|
8529
|
+
return {"error": "The selected futures credential is unavailable"}
|
|
8530
|
+
connection = config.get("connection") or {}
|
|
8531
|
+
owner = tuple(connection.get(key) for key in ("broker_node_id", "credential_id", "paper_trading"))
|
|
8532
|
+
prior_owner = getattr(context, "_futures_order_event_owner", None)
|
|
8533
|
+
if prior_owner is not None and prior_owner != owner:
|
|
8534
|
+
return {"error": "A futures event stream cannot be shared across broker connections"}
|
|
8535
|
+
context._futures_order_event_owner = owner
|
|
8536
|
+
else:
|
|
8537
|
+
credential = context.get_credential()
|
|
8483
8538
|
|
|
8484
8539
|
if not credential:
|
|
8485
8540
|
context.log("error", "Credential not found in secrets", node_id)
|
|
@@ -8487,7 +8542,8 @@ class RealOrderEventNodeExecutor(NodeExecutorBase):
|
|
|
8487
8542
|
|
|
8488
8543
|
appkey = credential.get("appkey")
|
|
8489
8544
|
appsecret = credential.get("appsecret")
|
|
8490
|
-
paper_trading =
|
|
8545
|
+
paper_trading = (config.get("connection", {}).get("paper_trading", False)
|
|
8546
|
+
if product == "overseas_futures" else credential.get("paper_trading", False))
|
|
8491
8547
|
|
|
8492
8548
|
if not appkey or not appsecret:
|
|
8493
8549
|
context.log("error", "appkey/appsecret not found in credential", node_id)
|
|
@@ -12130,7 +12186,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12130
12186
|
HistoricalDataNode executor - 과거 OHLCV 데이터 조회
|
|
12131
12187
|
|
|
12132
12188
|
LS Finance Chart API를 사용하여 과거 차트 데이터를 조회합니다.
|
|
12133
|
-
-
|
|
12189
|
+
- Overseas stock: g3204 (daily/weekly/monthly/yearly; no intraday)
|
|
12134
12190
|
- 해외선물: o3108 (일봉), o3103 (분봉)
|
|
12135
12191
|
"""
|
|
12136
12192
|
|
|
@@ -12152,6 +12208,15 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12152
12208
|
) -> Dict[str, Any]:
|
|
12153
12209
|
"""과거 데이터 조회"""
|
|
12154
12210
|
|
|
12211
|
+
# Reject unsupported stock periods before fixtures can manufacture success.
|
|
12212
|
+
# The broker-specific fetch repeats this guard for direct/legacy callers.
|
|
12213
|
+
connection = config.get("connection")
|
|
12214
|
+
product = connection.get("product") if isinstance(connection, dict) else None
|
|
12215
|
+
if node_type == "OverseasStockHistoricalDataNode" or (
|
|
12216
|
+
node_type == "HistoricalDataNode" and product in (None, "overseas_stock")
|
|
12217
|
+
):
|
|
12218
|
+
self._stock_history_gubun(config.get("interval", "1d"))
|
|
12219
|
+
|
|
12155
12220
|
# deep_validate: HistoricalDataNode hits the real LS API. Inject a fixture
|
|
12156
12221
|
# time series so the flow completes without a network call.
|
|
12157
12222
|
if context.is_deep_validate:
|
|
@@ -12395,6 +12460,18 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12395
12460
|
# 파싱 실패 시 오늘 날짜 반환
|
|
12396
12461
|
return datetime.now().strftime("%Y%m%d")
|
|
12397
12462
|
|
|
12463
|
+
@staticmethod
|
|
12464
|
+
def _stock_history_gubun(interval: str) -> str:
|
|
12465
|
+
"""Reject unsupported periods instead of relabeling daily bars."""
|
|
12466
|
+
periods = {"1d": "2", "1w": "3", "1M": "4", "1Y": "5"}
|
|
12467
|
+
if not isinstance(interval, str) or interval not in periods:
|
|
12468
|
+
raise ValueError(
|
|
12469
|
+
"UNSUPPORTED_STOCK_HISTORY_INTERVAL: OverseasStockHistoricalDataNode "
|
|
12470
|
+
"supports 1d, 1w, 1M and 1Y through LS g3204. "
|
|
12471
|
+
"Intraday g3203 is not supported; select a supported period explicitly."
|
|
12472
|
+
)
|
|
12473
|
+
return periods[interval]
|
|
12474
|
+
|
|
12398
12475
|
async def _fetch_overseas_stock(
|
|
12399
12476
|
self,
|
|
12400
12477
|
symbols: List[str],
|
|
@@ -12416,6 +12493,9 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12416
12493
|
반환: [{symbol, exchange, time_series: [{date, open, high, low, close, volume}, ...]}, ...]
|
|
12417
12494
|
"""
|
|
12418
12495
|
|
|
12496
|
+
# Validate before credential access or login, outside the soft-failure handler.
|
|
12497
|
+
gubun = self._stock_history_gubun(interval)
|
|
12498
|
+
|
|
12419
12499
|
credential = context.get_credential()
|
|
12420
12500
|
if not credential:
|
|
12421
12501
|
context.log("error", "Credential not set for HistoricalDataNode", node_id)
|
|
@@ -12438,10 +12518,6 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
|
|
|
12438
12518
|
|
|
12439
12519
|
api = ls.overseas_stock()
|
|
12440
12520
|
|
|
12441
|
-
# interval → gubun 변환 (g3204: 2=일, 3=주, 4=월, 5=년)
|
|
12442
|
-
gubun_map = {"1d": "2", "1w": "3", "1M": "4", "1Y": "5"}
|
|
12443
|
-
gubun = gubun_map.get(interval, "2")
|
|
12444
|
-
|
|
12445
12521
|
# 거래소 코드 매핑 (역변환용)
|
|
12446
12522
|
exchcd_to_exchange = {"82": "NASDAQ", "81": "NYSE", "83": "AMEX"}
|
|
12447
12523
|
|
|
@@ -19886,7 +19962,8 @@ class SQLiteNodeExecutor(NodeExecutorBase):
|
|
|
19886
19962
|
import os
|
|
19887
19963
|
from programgarden.database.query_builder import SQLQueryBuilder
|
|
19888
19964
|
|
|
19889
|
-
|
|
19965
|
+
from programgarden.database.sqlite_scope import scoped_sqlite_filename
|
|
19966
|
+
db_name = scoped_sqlite_filename(config, context)
|
|
19890
19967
|
operation = config.get("operation", "simple")
|
|
19891
19968
|
|
|
19892
19969
|
# dry_run 모드에서는 SELECT만 허용. INSERT/UPDATE/DELETE/UPSERT는
|
|
@@ -20334,6 +20411,7 @@ class WorkflowExecutor:
|
|
|
20334
20411
|
"OverseasStockSymbolQueryNode": SymbolQueryNodeExecutor(),
|
|
20335
20412
|
"OverseasFuturesSymbolQueryNode": SymbolQueryNodeExecutor(),
|
|
20336
20413
|
"FuturesContractNode": FuturesContractNodeExecutor(),
|
|
20414
|
+
"OverseasFuturesContractInfoNode": FuturesContractInfoNodeExecutor(),
|
|
20337
20415
|
"SymbolFilterNode": SymbolFilterNodeExecutor(),
|
|
20338
20416
|
"ExclusionListNode": ExclusionListNodeExecutor(),
|
|
20339
20417
|
"MarketUniverseNode": MarketUniverseNodeExecutor(),
|
|
@@ -22055,6 +22133,7 @@ class WorkflowJob:
|
|
|
22055
22133
|
# 종목 마스터 조회 노드 — 배열을 **생성**하는 쪽이다. 상류에 배열이 붙었다고
|
|
22056
22134
|
# 아이템 수만큼 마스터 조회를 반복하면 같은 전체 목록을 N 번 받아온다.
|
|
22057
22135
|
"FuturesContractNode",
|
|
22136
|
+
"OverseasFuturesContractInfoNode", # One exact identity; explicit SplitNode for a batch.
|
|
22058
22137
|
"OverseasFuturesSymbolQueryNode", "OverseasStockSymbolQueryNode", "KoreaStockSymbolQueryNode",
|
|
22059
22138
|
"SymbolFilterNode", # 집합 연산 노드 (배열 입력/출력)
|
|
22060
22139
|
# LogicNode 도 집합 연산 노드다 — 상류 조건 결과 **배열 전체**를 AND/OR 해야 한다.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Strict o3105 detail projection shared by live nodes and offline replay.
|
|
2
|
+
|
|
3
|
+
Request fields follow finance/example/overseas_futureoption/run_o3105.py.
|
|
4
|
+
No current quote is needed to read valid static contract metadata.
|
|
5
|
+
"""
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105InBlock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TEXT_FIELDS = {
|
|
13
|
+
"symbol_name": "SymbolNm", "underlying_code": "BscGdsCd", "currency": "CrncyCd",
|
|
14
|
+
"trade_status_code": "DlPsblCd",
|
|
15
|
+
}
|
|
16
|
+
DATE_FIELDS = {
|
|
17
|
+
"maturity_date": "MtrtDt", "last_trading_date": "FnlDlDt", "first_notice_date": "FstTrsfrDt",
|
|
18
|
+
"master_received_date": "ApplDate", "korea_start_date": "DlDt",
|
|
19
|
+
"local_start_date": "OvsStrDay", "local_end_date": "OvsEndDay",
|
|
20
|
+
}
|
|
21
|
+
TIME_FIELDS = {"korea_start_time": "DlStrtTm", "korea_end_time": "DlEndTm",
|
|
22
|
+
"local_start_time": "OvsStrTm", "local_end_time": "OvsEndTm"}
|
|
23
|
+
NUMBER_FIELDS = {"initial_margin": "OpngMgn", "maintenance_margin": "MntncMgn",
|
|
24
|
+
"tick_size": "UntPrc", "tick_value": "MnChgAmt"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FuturesContractEvidenceError(ValueError):
|
|
28
|
+
"""Safe-to-display contract evidence error without broker payloads or secrets."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_contract_info_request(config):
|
|
32
|
+
identity = config.get("symbol")
|
|
33
|
+
if not isinstance(identity, dict) or not all(
|
|
34
|
+
isinstance(identity.get(key), str) and identity[key].strip() for key in ("symbol", "exchange")
|
|
35
|
+
):
|
|
36
|
+
raise FuturesContractEvidenceError("An exact futures contract symbol and exchange are required")
|
|
37
|
+
return O3105InBlock(symbol=identity["symbol"].strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _text(block, field):
|
|
41
|
+
value = getattr(block, field) if field in block.model_fields_set else None
|
|
42
|
+
return value.strip() or None if isinstance(value, str) else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _date_or_time(block, field, *, date):
|
|
46
|
+
value = _text(block, field)
|
|
47
|
+
if not value or len(value) != (8 if date else 6) or not value.isascii() or not value.isdigit():
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
parsed = datetime.strptime(value, "%Y%m%d" if date else "%H%M%S")
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
return parsed.date().isoformat() if date else parsed.time().isoformat()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_contract_info(response, config, *, observed_at=None):
|
|
57
|
+
request = build_contract_info_request(config)
|
|
58
|
+
if response.status_code != 200 or response.error_msg or response.rsp_cd != "00000" or response.block is None:
|
|
59
|
+
raise FuturesContractEvidenceError("o3105 did not return a successful contract detail response")
|
|
60
|
+
block = response.block
|
|
61
|
+
if not {"Symbol", "ExchCd"} <= block.model_fields_set or block.Symbol.strip() != request.symbol:
|
|
62
|
+
raise FuturesContractEvidenceError("o3105 contract identity is absent or mismatched")
|
|
63
|
+
exchange = block.ExchCd.strip()
|
|
64
|
+
if exchange != config["symbol"]["exchange"].strip():
|
|
65
|
+
raise FuturesContractEvidenceError("o3105 exchange identity is absent or mismatched")
|
|
66
|
+
now = observed_at or datetime.now(UTC)
|
|
67
|
+
if now.tzinfo is None:
|
|
68
|
+
raise FuturesContractEvidenceError("Contract observation requires a timezone-aware timestamp")
|
|
69
|
+
value = {"symbol": block.Symbol.strip(), "exchange": exchange,
|
|
70
|
+
"source_tr": "o3105", "observed_at": now.astimezone(UTC).isoformat()}
|
|
71
|
+
value.update({name: _text(block, field) for name, field in TEXT_FIELDS.items()})
|
|
72
|
+
for fields, is_date in ((DATE_FIELDS, True), (TIME_FIELDS, False)):
|
|
73
|
+
value.update({name: _date_or_time(block, field, date=is_date) for name, field in fields.items()})
|
|
74
|
+
for name, field in NUMBER_FIELDS.items():
|
|
75
|
+
observed = getattr(block, field) if field in block.model_fields_set else None
|
|
76
|
+
valid = type(observed) in (int, float) and math.isfinite(observed) and observed >= 0
|
|
77
|
+
if name in ("tick_size", "tick_value"):
|
|
78
|
+
valid = valid and observed > 0
|
|
79
|
+
value[name] = float(observed) if valid else None
|
|
80
|
+
return {"value": value, "verified": True, "missing_fields": sorted(k for k, v in value.items() if v is None), "error": None}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def synthetic_contract_info(config, *, observed_at=None):
|
|
84
|
+
"""Legacy deep-check shape only; replay instead requires raw recorded evidence."""
|
|
85
|
+
from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105OutBlock, O3105Response
|
|
86
|
+
request = build_contract_info_request(config)
|
|
87
|
+
response = O3105Response(status_code=200, rsp_cd="00000", rsp_msg="Synthetic deep-check identity", block=O3105OutBlock(
|
|
88
|
+
Symbol=request.symbol, ExchCd=config["symbol"]["exchange"].strip(),
|
|
89
|
+
))
|
|
90
|
+
# Unknown dates/margins intentionally stay unknown; do not manufacture rollover permission.
|
|
91
|
+
return read_contract_info(response, config, observed_at=observed_at)
|
|
@@ -12,9 +12,20 @@ from programgarden.context import WorkflowEvent
|
|
|
12
12
|
from programgarden.replay_contracts import ContractViolation, check_contract
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
DEFAULT_EVENT_LIMIT = 32
|
|
16
|
+
MAX_OFFLINE_EVENT_LIMIT = 4096
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def checked_events(fixture, *, event_limit=DEFAULT_EVENT_LIMIT):
|
|
20
|
+
"""Allow a trusted offline caller to raise the bound, never a fixture field.
|
|
21
|
+
|
|
22
|
+
Interactive worker callers retain 32 events. Longer acceptance runs keep all
|
|
23
|
+
cron instants and one disposable state store, bounded by replay's timeout.
|
|
24
|
+
"""
|
|
25
|
+
if type(event_limit) is not int or not 0 <= event_limit <= MAX_OFFLINE_EVENT_LIMIT:
|
|
26
|
+
raise ContractViolation("events", "Event limit must be an integer from 0 to 4096")
|
|
16
27
|
events = fixture.get("events", [])
|
|
17
|
-
check_contract(events, {"type": "array", "maxItems":
|
|
28
|
+
check_contract(events, {"type": "array", "maxItems": event_limit, "items": {
|
|
18
29
|
"type": "object", "required": ["as_of", "type", "source_node_id"],
|
|
19
30
|
"additionalProperties": False, "properties": {
|
|
20
31
|
"as_of": {"type": "string", "format": "date-time"},
|
|
@@ -79,8 +90,8 @@ def event_fixture(fixture, event):
|
|
|
79
90
|
return frame
|
|
80
91
|
|
|
81
92
|
|
|
82
|
-
async def replay_events(job, runner, fixture, outcome):
|
|
83
|
-
events = checked_events(fixture)
|
|
93
|
+
async def replay_events(job, runner, fixture, outcome, *, event_limit=DEFAULT_EVENT_LIMIT):
|
|
94
|
+
events = checked_events(fixture, event_limit=event_limit)
|
|
84
95
|
# Ancestor-only node checks may not yet contain a later event source. Final
|
|
85
96
|
# verification checks all declared events against the complete graph.
|
|
86
97
|
pending = iter((index, event) for index, event in enumerate(events)
|
|
@@ -23,7 +23,7 @@ def check_financial_expectations(simulation, schema):
|
|
|
23
23
|
raise ContractViolation("live_order_count", "Replay must never submit live orders")
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def check_final_expectations(result, fixture, graph):
|
|
26
|
+
def check_final_expectations(result, fixture, graph, *, event_limit=32):
|
|
27
27
|
"""Final acceptance asserts outputs and financial state, not just no error."""
|
|
28
28
|
expected = fixture.get("expected")
|
|
29
29
|
if not isinstance(expected,dict) or not expected:
|
|
@@ -39,7 +39,7 @@ def check_final_expectations(result, fixture, graph):
|
|
|
39
39
|
if node_id not in result.initial_executed:
|
|
40
40
|
raise ContractViolation(node_id, "Required path was not reached")
|
|
41
41
|
from programgarden.replay_events import checked_events
|
|
42
|
-
events = checked_events(fixture)
|
|
42
|
+
events = checked_events(fixture, event_limit=event_limit)
|
|
43
43
|
if len(events) != len(result.events):
|
|
44
44
|
raise ContractViolation("events", "Every declared final replay event must be observed")
|
|
45
45
|
has_orders = any(node["type"] in ORDER_NODES for node in graph["nodes"])
|
|
@@ -10,7 +10,7 @@ from programgarden.replay_external import external_record
|
|
|
10
10
|
from programgarden.replay_triggers import fixture_instant
|
|
11
11
|
|
|
12
12
|
SOURCE_NODES = frozenset({"MarketUniverseNode",
|
|
13
|
-
"FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode"})
|
|
13
|
+
"FuturesContractNode", "ScreenerNode", "OverseasFuturesOrderableQuantityNode", "OverseasFuturesContractInfoNode"})
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def source_contract_catalog():
|
|
@@ -21,6 +21,7 @@ def source_contract_catalog():
|
|
|
21
21
|
The parser and independently written assertions remain authoritative.
|
|
22
22
|
"""
|
|
23
23
|
from programgarden_finance.ls.overseas_futureoption.market.o3101.blocks import O3101OutBlock
|
|
24
|
+
from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105Response
|
|
24
25
|
from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01400.blocks import CIDBQ01400Response
|
|
25
26
|
from programgarden_finance.ls.overseas_stock.market.g3101.blocks import G3101OutBlock
|
|
26
27
|
from programgarden.executor import MarketUniverseNodeExecutor
|
|
@@ -39,6 +40,11 @@ def source_contract_catalog():
|
|
|
39
40
|
"required_row_fields": ["Symbol", "BscGdsCd", "ExchCd", "LstngYr", "LstngM"],
|
|
40
41
|
"notes": "Supply raw o3101 model fields, including explicit contract identity/month. "
|
|
41
42
|
"Native expiry/exchange/front-next selection runs at as_of; do not preselect results."},
|
|
43
|
+
"OverseasFuturesContractInfoNode": {"source_schema": O3105Response.model_json_schema(),
|
|
44
|
+
"notes": "Use the raw SDK o3105 response envelope, with status_code=200, rsp_cd=00000, "
|
|
45
|
+
"no error_msg, and matching block.Symbol and block.ExchCd. Optional dates use "
|
|
46
|
+
"YYYYMMDD, times HHMMSS. Omitted fields stay null; no quote is required. "
|
|
47
|
+
"The recorded response is parsed by the native live detail parser."},
|
|
42
48
|
"OverseasFuturesOrderableQuantityNode": {"source_schema": CIDBQ01400Response.model_json_schema(),
|
|
43
49
|
"notes": "Use the SDK response envelope (block1, block2, status_code, rsp_cd, error_msg), "
|
|
44
50
|
"not LS wire block names. status_code=200, error_msg absent and rsp_cd=00000 or00136 "
|
|
@@ -104,6 +110,11 @@ async def execute_source(node, config, fixture, context):
|
|
|
104
110
|
return FuturesContractNodeExecutor().select_recorded_master(rows,
|
|
105
111
|
[p.strip().upper() for p in node.base_products], node.contract_selection, exchange,
|
|
106
112
|
context, node.id, as_of=fixture_instant(context, node.id))
|
|
113
|
+
if node.type == "OverseasFuturesContractInfoNode":
|
|
114
|
+
from programgarden.futures_contract_info import read_contract_info
|
|
115
|
+
from programgarden_finance.ls.overseas_futureoption.market.o3105.blocks import O3105Response
|
|
116
|
+
response = O3105Response.model_validate(source, strict=True)
|
|
117
|
+
return read_contract_info(response, config, observed_at=fixture_instant(context, node.id))
|
|
107
118
|
if node.type == "OverseasFuturesOrderableQuantityNode":
|
|
108
119
|
from programgarden.futures_orderable import build_orderable_request, read_orderable_quantity
|
|
109
120
|
from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01400.blocks import CIDBQ01400Response
|
|
@@ -20,7 +20,8 @@ async def execute_sqlite(node_id, config, context):
|
|
|
20
20
|
if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,119}\.db", name):
|
|
21
21
|
raise ContractViolation(node_id, "Replay SQLite requires a plain .db filename")
|
|
22
22
|
root = Path(context._storage_dir).resolve()
|
|
23
|
-
|
|
23
|
+
from programgarden.database.sqlite_scope import scoped_sqlite_filename
|
|
24
|
+
path = root / scoped_sqlite_filename(config, context)
|
|
24
25
|
if path.is_symlink() or path.resolve().parent != root:
|
|
25
26
|
raise ContractViolation(node_id, "SQLite path leaves the replay workspace")
|
|
26
27
|
operation = config.get("operation", "simple")
|
|
@@ -345,6 +345,7 @@ class WorkflowResolver:
|
|
|
345
345
|
|
|
346
346
|
# Order routing choices are literals, never prices or item bindings.
|
|
347
347
|
self._validate_order_enums(workflow, registry, result)
|
|
348
|
+
self._validate_stock_history_interval(workflow, registry, result)
|
|
348
349
|
|
|
349
350
|
# 11. Static recommendations (topology analysis)
|
|
350
351
|
for rec in run_static_recommendation_rules(
|
|
@@ -362,6 +363,29 @@ class WorkflowResolver:
|
|
|
362
363
|
|
|
363
364
|
return result
|
|
364
365
|
|
|
366
|
+
def _validate_stock_history_interval(self, workflow, registry, result) -> None:
|
|
367
|
+
"""Keep static/deep validation aligned with the stock chart executor."""
|
|
368
|
+
node_type = "OverseasStockHistoricalDataNode"
|
|
369
|
+
node_class = registry.get(node_type)
|
|
370
|
+
if node_class is None:
|
|
371
|
+
return
|
|
372
|
+
field = node_class.get_field_schema()["interval"]
|
|
373
|
+
for node in workflow.nodes:
|
|
374
|
+
if node.get("type") != node_type:
|
|
375
|
+
continue
|
|
376
|
+
if node.get("interval", field.default) in field.enum_values:
|
|
377
|
+
continue
|
|
378
|
+
result.add(build_error(
|
|
379
|
+
ErrorCode.INVALID_FIELD_ENUM,
|
|
380
|
+
"Stock historical intervals require a supported LS g3204 period; "
|
|
381
|
+
"intraday g3203 is not supported.",
|
|
382
|
+
location=ErrorLocation(
|
|
383
|
+
node_id=node.get("id"), node_type=node_type, field_path="interval",
|
|
384
|
+
),
|
|
385
|
+
available_values=field.enum_values,
|
|
386
|
+
suggestion="Choose 1d, 1w, 1M or 1Y explicitly; do not substitute daily bars for an intraday strategy.",
|
|
387
|
+
))
|
|
388
|
+
|
|
365
389
|
def _validate_order_enums(self, workflow, registry, result) -> None:
|
|
366
390
|
"""Enforce fixed price/order types without changing dynamic close sides."""
|
|
367
391
|
from programgarden_core.models.field_binding import ExpressionMode
|
|
@@ -349,7 +349,8 @@ class ReplayExecutor(WorkflowExecutor):
|
|
|
349
349
|
|
|
350
350
|
|
|
351
351
|
async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
|
|
352
|
-
timeout: float = 30, node_under_test: str | None = None
|
|
352
|
+
timeout: float = 30, node_under_test: str | None = None,
|
|
353
|
+
event_limit: int = 32) -> ReplayResult:
|
|
353
354
|
"""Run actual scheduler/mappings in disposable state; no silent soft PASS.
|
|
354
355
|
|
|
355
356
|
A node check executes its actual upstream setup in the same disposable
|
|
@@ -358,6 +359,9 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
|
|
|
358
359
|
node_under_test and always start with another fresh workspace.
|
|
359
360
|
An empty branch is not an exception; required activation/output expectations
|
|
360
361
|
belong to the suite and are checked explicitly by the coordinator.
|
|
362
|
+
Offline acceptance may explicitly raise event_limit (at most 4096). The
|
|
363
|
+
default worker/save path stays at 32. Every scheduled instant is still
|
|
364
|
+
required; state and the timeout span the whole timeline without resets.
|
|
361
365
|
"""
|
|
362
366
|
outcome = ReplayResult(False, content_hash(definition), content_hash(fixture))
|
|
363
367
|
outcome.node_under_test = node_under_test
|
|
@@ -391,6 +395,9 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
|
|
|
391
395
|
context.set_workflow_job(job)
|
|
392
396
|
context.start()
|
|
393
397
|
async def run_all():
|
|
398
|
+
# Reject an oversized/malformed timeline before any initial action.
|
|
399
|
+
from programgarden.replay_events import checked_events
|
|
400
|
+
checked_events(fixture, event_limit=event_limit)
|
|
394
401
|
if any(node.node_type in ORDER_NODES for node in resolved.nodes.values()):
|
|
395
402
|
# A no-signal branch still has account state to verify. Starting
|
|
396
403
|
# the book only on the first order loses unchanged cash/holdings
|
|
@@ -407,7 +414,7 @@ async def replay(definition: dict[str, Any], fixture: dict[str, Any], *,
|
|
|
407
414
|
for node in resolved.nodes}
|
|
408
415
|
outcome.main_executed = list(outcome.executed)
|
|
409
416
|
from programgarden.replay_events import replay_events
|
|
410
|
-
await replay_events(job, runner, fixture, outcome)
|
|
417
|
+
await replay_events(job, runner, fixture, outcome, event_limit=event_limit)
|
|
411
418
|
|
|
412
419
|
try:
|
|
413
420
|
await asyncio.wait_for(run_all(), timeout=timeout)
|
|
@@ -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.6.1"
|
|
9
9
|
license = "AGPL-3.0-or-later"
|
|
10
10
|
description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
|
|
11
11
|
readme = "README.md"
|
|
@@ -36,7 +36,7 @@ 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.6.1"
|
|
40
40
|
programgarden-finance = "^2.0.1"
|
|
41
41
|
programgarden-community = "^2.3.1"
|
|
42
42
|
|
|
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.5.6 → programgarden-2.6.1}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
{programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/position_reconciliation.py
RENAMED
|
File without changes
|
|
File without changes
|
{programgarden-2.5.6 → programgarden-2.6.1}/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
|
|
File without changes
|
|
File without changes
|