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.
Files changed (68) hide show
  1. {programgarden-2.5.6 → programgarden-2.6.1}/PKG-INFO +15 -2
  2. {programgarden-2.5.6 → programgarden-2.6.1}/README.md +13 -0
  3. programgarden-2.6.1/programgarden/database/sqlite_scope.py +19 -0
  4. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/executor.py +87 -8
  5. programgarden-2.6.1/programgarden/futures_contract_info.py +91 -0
  6. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_events.py +15 -4
  7. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_scenarios.py +2 -2
  8. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_sources.py +12 -1
  9. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_sqlite.py +2 -1
  10. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resolver.py +24 -0
  11. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/validation_replay.py +9 -2
  12. {programgarden-2.5.6 → programgarden-2.6.1}/pyproject.toml +2 -2
  13. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/__init__.py +0 -0
  14. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/binding_validator.py +0 -0
  15. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/client.py +0 -0
  16. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/code_worker.py +0 -0
  17. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/context.py +0 -0
  18. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/__init__.py +0 -0
  19. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/adjustment_delivery.py +0 -0
  20. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_evidence.py +0 -0
  21. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_order_totals.py +0 -0
  22. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/broker_snapshot.py +0 -0
  23. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/checkpoint_manager.py +0 -0
  24. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/db_naming.py +0 -0
  25. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/execution_storage.py +0 -0
  26. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/fill_reconciler.py +0 -0
  27. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/order_cancellation.py +0 -0
  28. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/order_recovery.py +0 -0
  29. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/owned_order_cancellation.py +0 -0
  30. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/position_reconciliation.py +0 -0
  31. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/query_builder.py +0 -0
  32. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/workflow_position_tracker.py +0 -0
  33. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/deep_fixtures.py +0 -0
  35. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_orderable.py +0 -0
  36. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_pnl.py +0 -0
  37. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/futures_read_evidence.py +0 -0
  38. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/incremental_build.py +0 -0
  39. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/managed_order_control.py +0 -0
  40. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/node_runner.py +0 -0
  41. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/order_lifecycle.py +0 -0
  42. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/plugin/__init__.py +0 -0
  43. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/plugin/sandbox.py +0 -0
  44. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/__init__.py +0 -0
  45. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/llm_errors.py +0 -0
  46. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/providers/llm_provider.py +0 -0
  47. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/reconnect_handler.py +0 -0
  48. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_contracts.py +0 -0
  49. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_external.py +0 -0
  50. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_order_adapter.py +0 -0
  51. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_orders.py +0 -0
  52. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_semantics.py +0 -0
  53. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/replay_triggers.py +0 -0
  54. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/__init__.py +0 -0
  55. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/context.py +0 -0
  56. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/limiter.py +0 -0
  57. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/monitor.py +0 -0
  58. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/resource/throttle.py +0 -0
  59. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/semantic_rules.py +0 -0
  60. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/__init__.py +0 -0
  61. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/credential_tools.py +0 -0
  62. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/definition_tools.py +0 -0
  63. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/event_tools.py +0 -0
  64. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/job_tools.py +0 -0
  65. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/registry_tools.py +0 -0
  66. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/tools/sqlite_tools.py +0 -0
  67. {programgarden-2.5.6 → programgarden-2.6.1}/programgarden/validation_recommender.py +0 -0
  68. {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.5.6
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.5.3,<3.0.0)
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
- credential = context.get_credential()
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 = credential.get("paper_trading", False)
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
- - 해외주식: g3103 (일/주/월봉)
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
- db_name = config.get("db_name", "default.db")
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
- def checked_events(fixture):
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": 32, "items": {
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
- path = root / name
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) -> ReplayResult:
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.5.6"
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.5.3"
39
+ programgarden-core = "^2.6.1"
40
40
  programgarden-finance = "^2.0.1"
41
41
  programgarden-community = "^2.3.1"
42
42