programgarden 2.6.1__tar.gz → 2.6.3__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 (70) hide show
  1. {programgarden-2.6.1 → programgarden-2.6.3}/PKG-INFO +9 -3
  2. {programgarden-2.6.1 → programgarden-2.6.3}/README.md +6 -0
  3. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/broker_evidence.py +5 -1
  4. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/broker_snapshot.py +15 -3
  5. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/executor.py +59 -21
  6. programgarden-2.6.3/programgarden/futures_history.py +23 -0
  7. programgarden-2.6.3/programgarden/paper_futures_business_date.py +96 -0
  8. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resolver.py +27 -0
  9. {programgarden-2.6.1 → programgarden-2.6.3}/pyproject.toml +3 -3
  10. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/__init__.py +0 -0
  11. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/binding_validator.py +0 -0
  12. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/client.py +0 -0
  13. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/code_worker.py +0 -0
  14. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/context.py +0 -0
  15. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/__init__.py +0 -0
  16. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/adjustment_delivery.py +0 -0
  17. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/broker_order_totals.py +0 -0
  18. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/checkpoint_manager.py +0 -0
  19. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/db_naming.py +0 -0
  20. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/execution_storage.py +0 -0
  21. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/fill_reconciler.py +0 -0
  22. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/order_cancellation.py +0 -0
  23. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/order_recovery.py +0 -0
  24. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/owned_order_cancellation.py +0 -0
  25. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/position_reconciliation.py +0 -0
  26. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/query_builder.py +0 -0
  27. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/sqlite_scope.py +0 -0
  28. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/workflow_position_tracker.py +0 -0
  29. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/database/workflow_risk_tracker.py +0 -0
  30. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/deep_fixtures.py +0 -0
  31. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/futures_contract_info.py +0 -0
  32. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/futures_orderable.py +0 -0
  33. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/futures_pnl.py +0 -0
  34. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/futures_read_evidence.py +0 -0
  35. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/incremental_build.py +0 -0
  36. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/managed_order_control.py +0 -0
  37. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/node_runner.py +0 -0
  38. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/order_lifecycle.py +0 -0
  39. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/plugin/__init__.py +0 -0
  40. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/plugin/sandbox.py +0 -0
  41. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/providers/__init__.py +0 -0
  42. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/providers/llm_errors.py +0 -0
  43. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/providers/llm_provider.py +0 -0
  44. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/reconnect_handler.py +0 -0
  45. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_contracts.py +0 -0
  46. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_events.py +0 -0
  47. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_external.py +0 -0
  48. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_order_adapter.py +0 -0
  49. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_orders.py +0 -0
  50. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_scenarios.py +0 -0
  51. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_semantics.py +0 -0
  52. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_sources.py +0 -0
  53. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_sqlite.py +0 -0
  54. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/replay_triggers.py +0 -0
  55. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resource/__init__.py +0 -0
  56. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resource/context.py +0 -0
  57. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resource/limiter.py +0 -0
  58. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resource/monitor.py +0 -0
  59. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/resource/throttle.py +0 -0
  60. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/semantic_rules.py +0 -0
  61. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/__init__.py +0 -0
  62. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/credential_tools.py +0 -0
  63. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/definition_tools.py +0 -0
  64. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/event_tools.py +0 -0
  65. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/job_tools.py +0 -0
  66. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/registry_tools.py +0 -0
  67. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/tools/sqlite_tools.py +0 -0
  68. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/validation_recommender.py +0 -0
  69. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/validation_replay.py +0 -0
  70. {programgarden-2.6.1 → programgarden-2.6.3}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.6.1
3
+ Version: 2.6.3
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,8 +16,8 @@ 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.6.1,<3.0.0)
20
- Requires-Dist: programgarden-finance (>=2.0.1,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.6.2,<3.0.0)
20
+ Requires-Dist: programgarden-finance (>=2.0.2,<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)
23
23
  Requires-Dist: pydantic (>=2.0.1,<3.0.0)
@@ -27,8 +27,14 @@ Requires-Dist: tzdata (>=2025.2,<2026.0)
27
27
  Requires-Dist: yfinance (>=0.2.0,<0.3.0)
28
28
  Description-Content-Type: text/markdown
29
29
 
30
+ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures-minute-alias-contract.md) aligns canonical minute periods with the existing one-page o3103 path and rejects unsupported hourly/adjustment claims. Complete-range coverage and consumer adoption are not implied.
31
+
30
32
  # ProgramGarden
31
33
 
34
+ The 2.6.3 release candidate [paper futures business-date correction](../../docs/paper-futures-business-date.md)
35
+ uses a fresh account-scoped broker date for startup pending orders, OpenOrdersNode
36
+ and legacy fill observation. Missing or mismatched evidence remains unavailable.
37
+
32
38
  An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
33
39
  rejects unsupported intraday periods instead of silently querying daily bars.
34
40
  Supported g3204 periods retain their existing requests and output contract.
@@ -1,5 +1,11 @@
1
+ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures-minute-alias-contract.md) aligns canonical minute periods with the existing one-page o3103 path and rejects unsupported hourly/adjustment claims. Complete-range coverage and consumer adoption are not implied.
2
+
1
3
  # ProgramGarden
2
4
 
5
+ The 2.6.3 release candidate [paper futures business-date correction](../../docs/paper-futures-business-date.md)
6
+ uses a fresh account-scoped broker date for startup pending orders, OpenOrdersNode
7
+ and legacy fill observation. Missing or mismatched evidence remains unavailable.
8
+
3
9
  An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
4
10
  rejects unsupported intraday periods instead of silently querying daily bars.
5
11
  Supported g3204 periods retain their existing requests and output contract.
@@ -40,7 +40,7 @@ class StartupQueryDateMismatch(StartupEvidenceUnavailable):
40
40
  self.business_day = business_day
41
41
 
42
42
 
43
- async def checked_body(response, tr, request=None):
43
+ async def checked_body(response, tr, request=None, *, paper_trading=False):
44
44
  """Read original broker blocks so SDK defaults cannot manufacture success."""
45
45
  if response.status_code != 200 or response.error_msg:
46
46
  raise StartupEvidenceUnavailable(f"{tr}: transport_failure")
@@ -105,6 +105,10 @@ async def checked_body(response, tr, request=None):
105
105
  and echo.get("BnsTpCode") == "0" and echo.get("CrcyCode") == "000")
106
106
  elif tr in {"CIDBQ01500", "CIDBQ02400"} and code == "00707":
107
107
  accepted = (not rows and response.rsp_msg == "모의투자 조회할 내역(자료)이 없습니다.")
108
+ elif paper_trading is True and tr in {"CIDBQ01500", "CIDBQ02400"} and code == "02679":
109
+ # Observed paper response with complete raw blocks, explicit empty rows
110
+ # and terminal continuation. The caller still binds date/account scope.
111
+ accepted = not rows and response.rsp_msg == "조회내역이 없습니다."
108
112
  if not accepted:
109
113
  raise StartupEvidenceUnavailable(f"{tr}: unverified_response_code={code}")
110
114
  if tr in {"t0424", "t0425"}:
@@ -110,12 +110,18 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
110
110
  source = "COSOQ00201+COSAQ00102"
111
111
  elif product == "overseas_futures":
112
112
  from programgarden_finance import CIDBQ01500, CIDBQ02400
113
+ from programgarden.paper_futures_business_date import PaperFuturesScope, from_current_positions
113
114
 
114
115
  accno = ls.overseas_futureoption().accno()
116
+ paper_scope = PaperFuturesScope.capture(ls) if trading_mode == "paper" else None
115
117
  query = accno.CIDBQ01500(body=CIDBQ01500.CIDBQ01500InBlock1(
116
118
  RecCnt=1, AcntTpCode="1", FcmAcntNo="", QryDt="", BalTpCode="2"))
117
119
  response = await query.req_async()
118
- await checked_body(response, "CIDBQ01500", query)
120
+ paper_day = None
121
+ if paper_scope is not None:
122
+ paper_day = await from_current_positions(ls, response, query, paper_scope)
123
+ else:
124
+ await checked_body(response, "CIDBQ01500", query)
119
125
  for row in response.block2:
120
126
  symbol = normalized_symbol(str(required(row, "IsuCodeVal")), product)
121
127
  side = str(required(row, "BnsTpCode"))
@@ -127,11 +133,17 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
127
133
  if side == "2":
128
134
  positions[symbol] = positions.get(symbol, Decimal(0)) + amount
129
135
  await asyncio.sleep(2)
136
+ if paper_day is not None:
137
+ paper_day.scope.check(ls)
138
+ query_day = paper_day.day if paper_day is not None else ""
130
139
  query = accno.CIDBQ02400(body=CIDBQ02400.CIDBQ02400InBlock1(
131
- RecCnt=1, IsuCodeVal="", QrySrtDt="", QryEndDt="", ThdayTpCode="1",
140
+ RecCnt=1, IsuCodeVal="", QrySrtDt=query_day, QryEndDt=query_day, ThdayTpCode="1",
132
141
  OrdStatCode="2", BnsTpCode="0", QryTpCode="2", OrdPtnCode="00", OvrsDrvtFnoTpCode="A"))
133
142
  response = await query.req_async()
134
- await checked_body(response, "CIDBQ02400", query)
143
+ if paper_day is not None:
144
+ await paper_day.check_orders(ls, response, query)
145
+ else:
146
+ await checked_body(response, "CIDBQ02400", query)
135
147
  pending = [value for row in response.block2 if (value := pending_row(row, product)) is not None]
136
148
  source = "CIDBQ01500+CIDBQ02400"
137
149
  else:
@@ -35,6 +35,12 @@ from programgarden_core import (
35
35
  OVERSEAS_STOCK_ORDER_MARKET_CODES,
36
36
  )
37
37
  from programgarden.context import ExecutionContext, WorkflowEvent
38
+ from programgarden.futures_history import (
39
+ DAILY_INTERVALS as FUTURES_DAILY_INTERVALS,
40
+ MINUTE_INTERVALS as FUTURES_MINUTE_INTERVALS,
41
+ normalize_interval as normalize_futures_history_interval,
42
+ validate_adjust as validate_futures_history_adjust,
43
+ )
38
44
  from programgarden.order_lifecycle import (
39
45
  FUTURES_PRODUCTS, OrderLifecycleMetadata, exact_futures_credential,
40
46
  inject_futures_connection, operation_key,
@@ -6653,21 +6659,33 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
6653
6659
  from datetime import datetime
6654
6660
  from zoneinfo import ZoneInfo
6655
6661
  from programgarden.futures_read_evidence import require_complete_rows, require_pending_identity
6662
+ from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
6663
+ from programgarden.database.broker_evidence import StartupEvidenceUnavailable
6656
6664
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import CIDBQ02400InBlock1
6657
6665
 
6658
6666
 
6659
- today = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y%m%d")
6667
+ paper_day = None
6668
+ try:
6669
+ if is_paper_client(ls):
6670
+ paper_day = await read_current_business_date(ls)
6671
+ except StartupEvidenceUnavailable as exc:
6672
+ return self._empty_result(str(exc))
6673
+ today = paper_day.day if paper_day else datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y%m%d")
6660
6674
  request = CIDBQ02400InBlock1(
6661
6675
  RecCnt=1, IsuCodeVal="", QrySrtDt=today, QryEndDt=today,
6662
6676
  ThdayTpCode="1", OrdStatCode="2", BnsTpCode="0", QryTpCode="2",
6663
6677
  OrdPtnCode="00", OvrsDrvtFnoTpCode="A",
6664
6678
  )
6665
- response = await ls.overseas_futureoption().accno().CIDBQ02400(body=request).req_async()
6666
6679
  try:
6667
- rows = require_complete_rows(response, request, "CIDBQ02400")
6680
+ if paper_day:
6681
+ paper_day.scope.check(ls)
6682
+ query = ls.overseas_futureoption().accno().CIDBQ02400(body=request)
6683
+ response = await query.req_async()
6684
+ rows = (await paper_day.check_orders(ls, response, query) if paper_day else
6685
+ require_complete_rows(response, request, "CIDBQ02400"))
6668
6686
  for row in rows:
6669
6687
  require_pending_identity(row)
6670
- except ValueError as exc:
6688
+ except (ValueError, StartupEvidenceUnavailable) as exc:
6671
6689
  return self._empty_result(str(exc))
6672
6690
 
6673
6691
  open_orders = []
@@ -12216,6 +12234,11 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12216
12234
  node_type == "HistoricalDataNode" and product in (None, "overseas_stock")
12217
12235
  ):
12218
12236
  self._stock_history_gubun(config.get("interval", "1d"))
12237
+ if node_type == "OverseasFuturesHistoricalDataNode" or (
12238
+ node_type == "HistoricalDataNode" and product in FUTURES_PRODUCTS
12239
+ ):
12240
+ normalize_futures_history_interval(config.get("interval", "1d"))
12241
+ validate_futures_history_adjust(config.get("adjust", False))
12219
12242
 
12220
12243
  # deep_validate: HistoricalDataNode hits the real LS API. Inject a fixture
12221
12244
  # time series so the flow completes without a network call.
@@ -12638,6 +12661,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12638
12661
 
12639
12662
  반환: [{symbol, exchange, time_series: [{date, open, high, low, close, volume}, ...]}, ...]
12640
12663
  """
12664
+ interval = normalize_futures_history_interval(interval)
12641
12665
  credential = context.get_credential()
12642
12666
  if not credential:
12643
12667
  context.log("error", "Credential not set for HistoricalDataNode(futures)", node_id)
@@ -12669,7 +12693,7 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12669
12693
  symbol_to_exchange[sym] = exch
12670
12694
 
12671
12695
  # interval별 분기
12672
- is_minute_data = interval in ["1min", "5min", "15min", "30min", "30s"]
12696
+ is_minute_data = interval in FUTURES_MINUTE_INTERVALS
12673
12697
 
12674
12698
  for symbol in symbols:
12675
12699
  try:
@@ -12720,8 +12744,10 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12720
12744
  from programgarden_finance.ls.overseas_futureoption.chart.o3108.blocks import O3108InBlock
12721
12745
 
12722
12746
  # interval → gubun 변환 (0=일, 1=주, 2=월)
12723
- gubun_map = {"1d": "0", "1w": "1", "1M": "2"}
12724
- gubun = gubun_map.get(interval, "0")
12747
+ interval = normalize_futures_history_interval(interval)
12748
+ if interval not in FUTURES_DAILY_INTERVALS:
12749
+ raise ValueError("FUTURES_HISTORY_DAILY_INTERVAL_REQUIRED")
12750
+ gubun = FUTURES_DAILY_INTERVALS[interval]
12725
12751
 
12726
12752
  body = O3108InBlock(
12727
12753
  shcode=symbol,
@@ -12763,17 +12789,21 @@ class HistoricalDataNodeExecutor(NodeExecutorBase):
12763
12789
  context: ExecutionContext,
12764
12790
  node_id: str,
12765
12791
  ) -> List[Dict[str, Any]]:
12766
- """해외선물 분봉 조회 (o3103)"""
12792
+ """Read the initial o3103 page; dates and complete-range coverage are not supplied."""
12767
12793
  from programgarden_finance.ls.overseas_futureoption.chart.o3103.blocks import O3103InBlock
12768
12794
 
12769
12795
  # interval → ncnt 변환 (0=30초, 1=1분, 5=5분, 30=30분)
12770
- ncnt_map = {"30s": 0, "1min": 1, "5min": 5, "15min": 15, "30min": 30}
12771
- ncnt = ncnt_map.get(interval, 1)
12796
+ interval = normalize_futures_history_interval(interval)
12797
+ if interval not in FUTURES_MINUTE_INTERVALS:
12798
+ raise ValueError("FUTURES_HISTORY_MINUTE_INTERVAL_REQUIRED")
12799
+ ncnt = FUTURES_MINUTE_INTERVALS[interval]
12772
12800
 
12773
12801
  body = O3103InBlock(
12774
12802
  shcode=symbol,
12775
12803
  ncnt=ncnt,
12776
- readcnt=200, # 최대 조회 건수
12804
+ readcnt=200, # Existing application request size, not a documented LS maximum.
12805
+ cts_date="",
12806
+ cts_time="",
12777
12807
  )
12778
12808
 
12779
12809
  result = await api.chart().o3103(body=body).req_async()
@@ -16831,17 +16861,21 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16831
16861
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import (
16832
16862
  CIDBQ02400InBlock1,
16833
16863
  )
16834
- # 당일조회(ThdayTpCode="1")에는 날짜를 싣지 않는다 — 브로커의 "당일"은
16835
- # 그쪽 영업일이고, 실행 머신의 달력 날짜와 다를 수 있다. 야간 세션에서
16836
- # 로컬이 자정을 넘기면 브로커는 아직 전 영업일이라(2026-09-10 00:39 KST
16837
- # 접수 주문이 OrdDt/ExecDt=20260909 로 조회됨) 로컬 날짜를 창으로 주면
16838
- # 방금 낸 주문의 체결을 스스로 잘라낸다. 아래 매칭은 주문번호로만 하므로
16839
- # 날짜 창은 필요 없다.
16840
- response = await ls.overseas_futureoption().accno().CIDBQ02400(
16864
+ from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
16865
+
16866
+ # A paper server can reject the documented blank same-day dates.
16867
+ # Use its fresh account-scoped date, including overnight sessions;
16868
+ # never replace missing evidence with the machine's calendar date.
16869
+ paper_day = await read_current_business_date(ls) if is_paper_client(ls) else None
16870
+ query_day = paper_day.day if paper_day else ""
16871
+ if paper_day:
16872
+ paper_day.scope.check(ls)
16873
+ query = ls.overseas_futureoption().accno().CIDBQ02400(
16841
16874
  body=CIDBQ02400InBlock1(
16875
+ RecCnt=1,
16842
16876
  IsuCodeVal="", # 전체 종목
16843
- QrySrtDt="",
16844
- QryEndDt="",
16877
+ QrySrtDt=query_day,
16878
+ QryEndDt=query_day,
16845
16879
  ThdayTpCode="1", # 1: 당일조회
16846
16880
  OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
16847
16881
  BnsTpCode="0", # 0: 전체
@@ -16849,7 +16883,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16849
16883
  OrdPtnCode="00", # 00: 전체
16850
16884
  OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
16851
16885
  )
16852
- ).req_async()
16886
+ )
16887
+ response = await query.req_async()
16888
+
16889
+ if paper_day:
16890
+ await paper_day.check_orders(ls, response, query)
16853
16891
 
16854
16892
  if getattr(response, "error_msg", None):
16855
16893
  context.log("debug", f"CIDBQ02400 fill query error: {response.error_msg}", node_id)
@@ -0,0 +1,23 @@
1
+ """Explicit mappings for the existing single-page LS futures chart requests."""
2
+
3
+ MINUTE_INTERVALS = {"30s": 0, "1m": 1, "5m": 5, "15m": 15, "30m": 30}
4
+ DAILY_INTERVALS = {"1d": "0", "1w": "1", "1M": "2"}
5
+ INTERVAL_ALIASES = {"1min": "1m", "5min": "5m", "15min": "15m", "30min": "30m"}
6
+
7
+
8
+ def normalize_interval(interval):
9
+ """Preserve known legacy minute names without falling back to daily data."""
10
+ if isinstance(interval, str):
11
+ interval = INTERVAL_ALIASES.get(interval, interval)
12
+ if interval in MINUTE_INTERVALS or interval in DAILY_INTERVALS:
13
+ return interval
14
+ raise ValueError(
15
+ "UNSUPPORTED_FUTURES_HISTORY_INTERVAL: choose 30s, 1m, 5m, 15m, 30m, "
16
+ "1d, 1w or 1M. Hourly and unknown periods are not implemented; no daily fallback."
17
+ )
18
+
19
+
20
+ def validate_adjust(adjust):
21
+ """Never describe exact-contract prices as rollover-adjusted history."""
22
+ if adjust is not False:
23
+ raise ValueError("FUTURES_HISTORY_ADJUST_UNSUPPORTED: adjust must be false; rollover adjustment is not implemented")
@@ -0,0 +1,96 @@
1
+ """Use fresh account-scoped broker dates for paper futures order inquiries.
2
+
3
+ The published same-day blank-date contract conflicts with an observed paper
4
+ response. This adapter uses CIDBQ01500's explicit current-date echo, never a
5
+ machine date, and does not cache it across calls or relax unavailable evidence.
6
+ """
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ import hashlib
10
+ import re
11
+ import time
12
+
13
+ from programgarden.database.broker_evidence import StartupEvidenceUnavailable, checked_body
14
+
15
+
16
+ def is_paper_client(ls):
17
+ return getattr(getattr(ls, "token_manager", None), "paper_trading", None) is True
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PaperFuturesScope:
22
+ manager: object = field(repr=False)
23
+ key_digest: str = field(repr=False)
24
+ started: float
25
+
26
+ @classmethod
27
+ def capture(cls, ls):
28
+ manager = getattr(ls, "token_manager", None)
29
+ key = getattr(manager, "appkey", None)
30
+ if not is_paper_client(ls) or not isinstance(key, str) or not key:
31
+ raise StartupEvidenceUnavailable("paper_futures_business_date: unavailable_client_scope")
32
+ return cls(manager, hashlib.sha256(key.encode()).hexdigest(), time.monotonic())
33
+
34
+ def check(self, ls):
35
+ current = self.capture(ls)
36
+ if current.manager is not self.manager or current.key_digest != self.key_digest:
37
+ raise StartupEvidenceUnavailable("paper_futures_business_date: client_scope_changed")
38
+ age = time.monotonic() - self.started
39
+ if age < 0 or age > 60:
40
+ raise StartupEvidenceUnavailable("paper_futures_business_date: expired_observation")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PaperFuturesBusinessDate:
45
+ day: str
46
+ account: str = field(repr=False)
47
+ scope: PaperFuturesScope = field(repr=False)
48
+
49
+ async def check_orders(self, ls, response, query):
50
+ self.scope.check(ls)
51
+ body = await checked_body(response, "CIDBQ02400", query, paper_trading=True)
52
+ echo = body["CIDBQ02400OutBlock1"]
53
+ request = next(iter(query.request_data.body.values()))
54
+ expected = request.model_dump(exclude={"RecCnt"})
55
+ if (request.ThdayTpCode != "1" or request.QrySrtDt != self.day
56
+ or request.QryEndDt != self.day or not isinstance(echo, dict)
57
+ or echo.get("AcntNo") != self.account
58
+ or any(name not in echo or echo[name] != value for name, value in expected.items())):
59
+ raise StartupEvidenceUnavailable("CIDBQ02400: business_date_scope_mismatch")
60
+ self.scope.check(ls)
61
+ return response.block2
62
+
63
+
64
+ async def from_current_positions(ls, response, query, scope):
65
+ """Validate a just-fetched positions response without making another call."""
66
+ scope.check(ls)
67
+ body = await checked_body(response, "CIDBQ01500", query, paper_trading=True)
68
+ echo = body["CIDBQ01500OutBlock1"]
69
+ request = next(iter(query.request_data.body.values()))
70
+ expected = {"AcntTpCode": "1", "QryDt": "", "BalTpCode": "2", "FcmAcntNo": ""}
71
+ if any(getattr(request, name) != value for name, value in expected.items()):
72
+ raise StartupEvidenceUnavailable("CIDBQ01500: current_date_query_required")
73
+ if not isinstance(echo, dict) or any(echo.get(name) != value for name, value in expected.items() if name != "QryDt"):
74
+ raise StartupEvidenceUnavailable("CIDBQ01500: business_date_scope_mismatch")
75
+ day, account = echo.get("QryDt"), echo.get("AcntNo")
76
+ if not isinstance(day, str) or not re.fullmatch(r"[0-9]{8}", day):
77
+ raise StartupEvidenceUnavailable("CIDBQ01500: unavailable_business_date")
78
+ try:
79
+ datetime.strptime(day, "%Y%m%d")
80
+ except ValueError:
81
+ raise StartupEvidenceUnavailable("CIDBQ01500: invalid_business_date") from None
82
+ if not isinstance(account, str) or not account.strip():
83
+ raise StartupEvidenceUnavailable("CIDBQ01500: unavailable_account_echo")
84
+ scope.check(ls)
85
+ return PaperFuturesBusinessDate(day, account, scope)
86
+
87
+
88
+ async def read_current_business_date(ls):
89
+ """One bounded read; the SDK retains its existing shared-key rate limiter."""
90
+ from programgarden_finance import CIDBQ01500
91
+
92
+ scope = PaperFuturesScope.capture(ls)
93
+ query = ls.overseas_futureoption().accno().CIDBQ01500(body=CIDBQ01500.CIDBQ01500InBlock1(
94
+ RecCnt=1, AcntTpCode="1", FcmAcntNo="", QryDt="", BalTpCode="2"))
95
+ response = await query.req_async()
96
+ return await from_current_positions(ls, response, query, scope)
@@ -346,6 +346,7 @@ class WorkflowResolver:
346
346
  # Order routing choices are literals, never prices or item bindings.
347
347
  self._validate_order_enums(workflow, registry, result)
348
348
  self._validate_stock_history_interval(workflow, registry, result)
349
+ self._validate_futures_history_contract(workflow, registry, result)
349
350
 
350
351
  # 11. Static recommendations (topology analysis)
351
352
  for rec in run_static_recommendation_rules(
@@ -386,6 +387,32 @@ class WorkflowResolver:
386
387
  suggestion="Choose 1d, 1w, 1M or 1Y explicitly; do not substitute daily bars for an intraday strategy.",
387
388
  ))
388
389
 
390
+ def _validate_futures_history_contract(self, workflow, registry, result) -> None:
391
+ """Keep native validation aligned with period and adjustment routing."""
392
+ from programgarden.futures_history import normalize_interval, validate_adjust
393
+
394
+ node_type = "OverseasFuturesHistoricalDataNode"
395
+ node_class = registry.get(node_type)
396
+ if node_class is None:
397
+ return
398
+ field = node_class.get_field_schema()["interval"]
399
+ for node in workflow.nodes:
400
+ if node.get("type") != node_type:
401
+ continue
402
+ for name, check, default, available in (
403
+ ("interval", normalize_interval, field.default, field.enum_values),
404
+ ("adjust", validate_adjust, False, ["false"]),
405
+ ):
406
+ try:
407
+ check(node.get(name, default))
408
+ except ValueError as exc:
409
+ result.add(build_error(
410
+ ErrorCode.INVALID_FIELD_ENUM, str(exc),
411
+ location=ErrorLocation(node_id=node.get("id"), node_type=node_type, field_path=name),
412
+ available_values=available,
413
+ suggestion="Choose a supported period and keep adjust=false; only exact-contract prices are implemented.",
414
+ ))
415
+
389
416
  def _validate_order_enums(self, workflow, registry, result) -> None:
390
417
  """Enforce fixed price/order types without changing dynamic close sides."""
391
418
  from programgarden_core.models.field_binding import ExpressionMode
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.6.1"
8
+ version = "2.6.3"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -36,8 +36,8 @@ 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.6.1"
40
- programgarden-finance = "^2.0.1"
39
+ programgarden-core = "^2.6.2"
40
+ programgarden-finance = "^2.0.2"
41
41
  programgarden-community = "^2.3.1"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]