programgarden 2.6.2__tar.gz → 2.6.4__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.2 → programgarden-2.6.4}/PKG-INFO +10 -2
  2. {programgarden-2.6.2 → programgarden-2.6.4}/README.md +8 -0
  3. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/broker_evidence.py +46 -5
  4. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/broker_snapshot.py +15 -3
  5. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/executor.py +40 -15
  6. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/futures_read_evidence.py +27 -8
  7. programgarden-2.6.4/programgarden/paper_futures_business_date.py +96 -0
  8. {programgarden-2.6.2 → programgarden-2.6.4}/pyproject.toml +2 -2
  9. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/__init__.py +0 -0
  10. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/binding_validator.py +0 -0
  11. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/client.py +0 -0
  12. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/code_worker.py +0 -0
  13. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/context.py +0 -0
  14. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/__init__.py +0 -0
  15. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/adjustment_delivery.py +0 -0
  16. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/broker_order_totals.py +0 -0
  17. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/checkpoint_manager.py +0 -0
  18. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/db_naming.py +0 -0
  19. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/execution_storage.py +0 -0
  20. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/fill_reconciler.py +0 -0
  21. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/order_cancellation.py +0 -0
  22. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/order_recovery.py +0 -0
  23. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/owned_order_cancellation.py +0 -0
  24. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/position_reconciliation.py +0 -0
  25. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/query_builder.py +0 -0
  26. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/sqlite_scope.py +0 -0
  27. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/workflow_position_tracker.py +0 -0
  28. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/database/workflow_risk_tracker.py +0 -0
  29. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/deep_fixtures.py +0 -0
  30. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/futures_contract_info.py +0 -0
  31. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/futures_history.py +0 -0
  32. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/futures_orderable.py +0 -0
  33. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/futures_pnl.py +0 -0
  34. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/incremental_build.py +0 -0
  35. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/managed_order_control.py +0 -0
  36. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/node_runner.py +0 -0
  37. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/order_lifecycle.py +0 -0
  38. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/plugin/__init__.py +0 -0
  39. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/plugin/sandbox.py +0 -0
  40. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/providers/__init__.py +0 -0
  41. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/providers/llm_errors.py +0 -0
  42. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/providers/llm_provider.py +0 -0
  43. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/reconnect_handler.py +0 -0
  44. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_contracts.py +0 -0
  45. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_events.py +0 -0
  46. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_external.py +0 -0
  47. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_order_adapter.py +0 -0
  48. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_orders.py +0 -0
  49. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_scenarios.py +0 -0
  50. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_semantics.py +0 -0
  51. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_sources.py +0 -0
  52. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_sqlite.py +0 -0
  53. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/replay_triggers.py +0 -0
  54. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resolver.py +0 -0
  55. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resource/__init__.py +0 -0
  56. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resource/context.py +0 -0
  57. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resource/limiter.py +0 -0
  58. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resource/monitor.py +0 -0
  59. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/resource/throttle.py +0 -0
  60. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/semantic_rules.py +0 -0
  61. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/__init__.py +0 -0
  62. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/credential_tools.py +0 -0
  63. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/definition_tools.py +0 -0
  64. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/event_tools.py +0 -0
  65. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/job_tools.py +0 -0
  66. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/registry_tools.py +0 -0
  67. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/tools/sqlite_tools.py +0 -0
  68. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/validation_recommender.py +0 -0
  69. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/validation_replay.py +0 -0
  70. {programgarden-2.6.2 → programgarden-2.6.4}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -17,7 +17,7 @@ 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
19
  Requires-Dist: programgarden-core (>=2.6.2,<3.0.0)
20
- Requires-Dist: programgarden-finance (>=2.0.1,<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)
@@ -31,6 +31,14 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
31
31
 
32
32
  # ProgramGarden
33
33
 
34
+ The 2.6.4 release candidate [paper futures account correction](../../docs/paper-futures-business-date.md#account-node-and-rejection-messages-264-candidate)
35
+ returns an empty futures account for the paper empty-account answer and names
36
+ the broker message in unaccepted response errors. Live behavior is unchanged.
37
+
38
+ The 2.6.3 release candidate [paper futures business-date correction](../../docs/paper-futures-business-date.md)
39
+ uses a fresh account-scoped broker date for startup pending orders, OpenOrdersNode
40
+ and legacy fill observation. Missing or mismatched evidence remains unavailable.
41
+
34
42
  An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
35
43
  rejects unsupported intraday periods instead of silently querying daily bars.
36
44
  Supported g3204 periods retain their existing requests and output contract.
@@ -2,6 +2,14 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
2
2
 
3
3
  # ProgramGarden
4
4
 
5
+ The 2.6.4 release candidate [paper futures account correction](../../docs/paper-futures-business-date.md#account-node-and-rejection-messages-264-candidate)
6
+ returns an empty futures account for the paper empty-account answer and names
7
+ the broker message in unaccepted response errors. Live behavior is unchanged.
8
+
9
+ The 2.6.3 release candidate [paper futures business-date correction](../../docs/paper-futures-business-date.md)
10
+ uses a fresh account-scoped broker date for startup pending orders, OpenOrdersNode
11
+ and legacy fill observation. Missing or mismatched evidence remains unavailable.
12
+
5
13
  An unreleased [stock history interval correction](../../docs/stock-history-interval-contract.md)
6
14
  rejects unsupported intraday periods instead of silently querying daily bars.
7
15
  Supported g3204 periods retain their existing requests and output contract.
@@ -5,6 +5,7 @@ not turn a missing detail block, partial page or rejection into an empty account
5
5
  """
6
6
 
7
7
  import inspect
8
+ import unicodedata
8
9
  from decimal import Decimal, InvalidOperation
9
10
  from datetime import datetime, timedelta, timezone
10
11
 
@@ -30,17 +31,50 @@ _OBSERVED_RESPONSE_CODES = {
30
31
  }
31
32
 
32
33
 
34
+ _BROKER_MESSAGE_LIMIT = 80
35
+
36
+ # Exact empty-result wording observed with rsp_cd 02679 on the stock reads below
37
+ # and on paper CIDBQ01500/CIDBQ02400 reads (2026-10-14). Not a generic success.
38
+ NO_RESULT_MESSAGE = "조회내역이 없습니다."
39
+ # The paper server's own empty-result wording, observed with rsp_cd 00707 on
40
+ # the two futures TRs below.
41
+ PAPER_NO_RESULT_MESSAGE = "모의투자 조회할 내역(자료)이 없습니다."
42
+ # The paper CIDBQ01500/CIDBQ02400 02679 branch accepts either exact wording:
43
+ # the same set the dsl-api startup gate uses for these two TRs
44
+ # (cloudrun/dsl-api/app/services/startup_broker.py, _NO_DATA_MESSAGES), so a
45
+ # start the server admits is not rejected by the engine. Stock branches keep
46
+ # the single NO_RESULT_MESSAGE, as the server gate does.
47
+ PAPER_FUTURES_NO_RESULT_MESSAGES = frozenset({NO_RESULT_MESSAGE, PAPER_NO_RESULT_MESSAGE})
48
+
49
+
33
50
  class StartupEvidenceUnavailable(RuntimeError):
34
51
  """An incomplete response must never authorize execution."""
35
52
 
36
53
 
54
+ def broker_message(value):
55
+ """Return the broker's diagnostic text as one bounded log-safe line.
56
+
57
+ Matches the server startup gate's ``str(rsp_msg or '')[:80]`` for ordinary
58
+ messages; control/format characters become spaces and outer whitespace is
59
+ stripped so a broker message cannot inject extra log lines.
60
+ """
61
+ text = "".join(" " if unicodedata.category(char).startswith("C") else char
62
+ for char in str(value or ""))
63
+ return text.strip()[:_BROKER_MESSAGE_LIMIT]
64
+
65
+
66
+ def unverified_response_code(tr, code, message):
67
+ """Keep the stable ``<TR>: unverified_response_code=<code>`` prefix."""
68
+ return f"{tr}: unverified_response_code={code} rsp_msg={broker_message(message)}"
69
+
70
+
37
71
  class StartupQueryDateMismatch(StartupEvidenceUnavailable):
38
72
  def __init__(self, business_day):
39
73
  super().__init__("COSAQ00102: query_echo_mismatch:OrdDt")
40
74
  self.business_day = business_day
41
75
 
42
76
 
43
- async def checked_body(response, tr, request=None):
77
+ async def checked_body(response, tr, request=None, *, paper_trading=False):
44
78
  """Read original broker blocks so SDK defaults cannot manufacture success."""
45
79
  if response.status_code != 200 or response.error_msg:
46
80
  raise StartupEvidenceUnavailable(f"{tr}: transport_failure")
@@ -95,18 +129,25 @@ async def checked_body(response, tr, request=None):
95
129
  if tr == "COSOQ00201" and code == "02679":
96
130
  # Observed authenticated empty stock balance, 2026-09-21. Explicit
97
131
  # blocks, terminal pagination and matching query scope remain required.
98
- accepted = not rows and response.rsp_msg == "조회내역이 없습니다."
132
+ accepted = not rows and response.rsp_msg == NO_RESULT_MESSAGE
99
133
  if tr == "COSAQ00102" and code == "02679":
100
134
  echo = body["COSAQ00102OutBlock1"]
101
135
  accepted = (not rows and isinstance(echo, dict)
102
- and response.rsp_msg == "조회내역이 없습니다."
136
+ and response.rsp_msg == NO_RESULT_MESSAGE
103
137
  and echo.get("OrdMktCode") == "%"
104
138
  and echo.get("ThdayBnsAppYn") == "1"
105
139
  and echo.get("BnsTpCode") == "0" and echo.get("CrcyCode") == "000")
106
140
  elif tr in {"CIDBQ01500", "CIDBQ02400"} and code == "00707":
107
- accepted = (not rows and response.rsp_msg == "모의투자 조회할 내역(자료)이 없습니다.")
141
+ accepted = (not rows and response.rsp_msg == PAPER_NO_RESULT_MESSAGE)
142
+ elif paper_trading is True and tr in {"CIDBQ01500", "CIDBQ02400"} and code == "02679":
143
+ # Observed paper response with complete raw blocks, explicit empty rows
144
+ # and terminal continuation. The caller still binds date/account scope.
145
+ # The wording set matches the server startup gate for these two TRs.
146
+ accepted = not rows and response.rsp_msg in PAPER_FUTURES_NO_RESULT_MESSAGES
108
147
  if not accepted:
109
- raise StartupEvidenceUnavailable(f"{tr}: unverified_response_code={code}")
148
+ # The broker message distinguishes, for example, an unrecognized empty
149
+ # wording from a rejected query; the stable reason prefix is unchanged.
150
+ raise StartupEvidenceUnavailable(unverified_response_code(tr, code, response.rsp_msg))
110
151
  if tr in {"t0424", "t0425"}:
111
152
  cursor = "cts_expcode" if tr == "t0424" else "cts_ordno"
112
153
  summary = body[_BLOCKS[tr][0]]
@@ -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:
@@ -6142,6 +6142,7 @@ class AccountNodeExecutor(NodeExecutorBase):
6142
6142
  from datetime import datetime
6143
6143
  from zoneinfo import ZoneInfo
6144
6144
  from programgarden.futures_read_evidence import require_complete_rows, require_position_identity
6145
+ from programgarden.paper_futures_business_date import is_paper_client
6145
6146
 
6146
6147
  try:
6147
6148
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ01500.blocks import CIDBQ01500InBlock1
@@ -6157,7 +6158,11 @@ class AccountNodeExecutor(NodeExecutorBase):
6157
6158
  response = await ls.overseas_futureoption().accno().CIDBQ01500(
6158
6159
  body=position_request,
6159
6160
  ).req_async()
6160
- position_rows = require_complete_rows(response, position_request, "CIDBQ01500")
6161
+ # A paper server answers an empty account with 02679; only that
6162
+ # exact empty envelope is accepted, and only for a paper client.
6163
+ position_rows = require_complete_rows(
6164
+ response, position_request, "CIDBQ01500", paper_trading=is_paper_client(ls),
6165
+ )
6161
6166
 
6162
6167
  # block2 = 종목별 잔고 (list 형태로 변환, NewOrderNode 호환)
6163
6168
  positions = []
@@ -6659,21 +6664,33 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
6659
6664
  from datetime import datetime
6660
6665
  from zoneinfo import ZoneInfo
6661
6666
  from programgarden.futures_read_evidence import require_complete_rows, require_pending_identity
6667
+ from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
6668
+ from programgarden.database.broker_evidence import StartupEvidenceUnavailable
6662
6669
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import CIDBQ02400InBlock1
6663
6670
 
6664
6671
 
6665
- today = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y%m%d")
6672
+ paper_day = None
6673
+ try:
6674
+ if is_paper_client(ls):
6675
+ paper_day = await read_current_business_date(ls)
6676
+ except StartupEvidenceUnavailable as exc:
6677
+ return self._empty_result(str(exc))
6678
+ today = paper_day.day if paper_day else datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y%m%d")
6666
6679
  request = CIDBQ02400InBlock1(
6667
6680
  RecCnt=1, IsuCodeVal="", QrySrtDt=today, QryEndDt=today,
6668
6681
  ThdayTpCode="1", OrdStatCode="2", BnsTpCode="0", QryTpCode="2",
6669
6682
  OrdPtnCode="00", OvrsDrvtFnoTpCode="A",
6670
6683
  )
6671
- response = await ls.overseas_futureoption().accno().CIDBQ02400(body=request).req_async()
6672
6684
  try:
6673
- rows = require_complete_rows(response, request, "CIDBQ02400")
6685
+ if paper_day:
6686
+ paper_day.scope.check(ls)
6687
+ query = ls.overseas_futureoption().accno().CIDBQ02400(body=request)
6688
+ response = await query.req_async()
6689
+ rows = (await paper_day.check_orders(ls, response, query) if paper_day else
6690
+ require_complete_rows(response, request, "CIDBQ02400"))
6674
6691
  for row in rows:
6675
6692
  require_pending_identity(row)
6676
- except ValueError as exc:
6693
+ except (ValueError, StartupEvidenceUnavailable) as exc:
6677
6694
  return self._empty_result(str(exc))
6678
6695
 
6679
6696
  open_orders = []
@@ -16849,17 +16866,21 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16849
16866
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import (
16850
16867
  CIDBQ02400InBlock1,
16851
16868
  )
16852
- # 당일조회(ThdayTpCode="1")에는 날짜를 싣지 않는다 — 브로커의 "당일"은
16853
- # 그쪽 영업일이고, 실행 머신의 달력 날짜와 다를 수 있다. 야간 세션에서
16854
- # 로컬이 자정을 넘기면 브로커는 아직 전 영업일이라(2026-09-10 00:39 KST
16855
- # 접수 주문이 OrdDt/ExecDt=20260909 로 조회됨) 로컬 날짜를 창으로 주면
16856
- # 방금 낸 주문의 체결을 스스로 잘라낸다. 아래 매칭은 주문번호로만 하므로
16857
- # 날짜 창은 필요 없다.
16858
- response = await ls.overseas_futureoption().accno().CIDBQ02400(
16869
+ from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
16870
+
16871
+ # A paper server can reject the documented blank same-day dates.
16872
+ # Use its fresh account-scoped date, including overnight sessions;
16873
+ # never replace missing evidence with the machine's calendar date.
16874
+ paper_day = await read_current_business_date(ls) if is_paper_client(ls) else None
16875
+ query_day = paper_day.day if paper_day else ""
16876
+ if paper_day:
16877
+ paper_day.scope.check(ls)
16878
+ query = ls.overseas_futureoption().accno().CIDBQ02400(
16859
16879
  body=CIDBQ02400InBlock1(
16880
+ RecCnt=1,
16860
16881
  IsuCodeVal="", # 전체 종목
16861
- QrySrtDt="",
16862
- QryEndDt="",
16882
+ QrySrtDt=query_day,
16883
+ QryEndDt=query_day,
16863
16884
  ThdayTpCode="1", # 1: 당일조회
16864
16885
  OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
16865
16886
  BnsTpCode="0", # 0: 전체
@@ -16867,7 +16888,11 @@ class NewOrderNodeExecutor(NodeExecutorBase):
16867
16888
  OrdPtnCode="00", # 00: 전체
16868
16889
  OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
16869
16890
  )
16870
- ).req_async()
16891
+ )
16892
+ response = await query.req_async()
16893
+
16894
+ if paper_day:
16895
+ await paper_day.check_orders(ls, response, query)
16871
16896
 
16872
16897
  if getattr(response, "error_msg", None):
16873
16898
  context.log("debug", f"CIDBQ02400 fill query error: {response.error_msg}", node_id)
@@ -2,19 +2,40 @@
2
2
 
3
3
  import math
4
4
 
5
+ from programgarden.database.broker_evidence import (
6
+ PAPER_FUTURES_NO_RESULT_MESSAGES,
7
+ unverified_response_code,
8
+ )
5
9
 
6
- def require_complete_rows(response, request, tr):
10
+ _NO_DATA_TRS = {"CIDBQ01500", "CIDBQ02400"}
11
+
12
+
13
+ def require_complete_rows(response, request, tr, *, paper_trading=False):
7
14
  """Validate the observed terminal envelope and return its detail rows.
8
15
 
9
16
  00707 is accepted only for an explicitly empty, matching terminal response
10
17
  for the two position/pending TRs observed on the paper server. It is not a
11
18
  generic success code. Continuation pages remain unavailable in this reader.
19
+
20
+ For a paper client only, 02679 with one of the exact paper empty-result
21
+ messages (the startup gate's set for these two TRs) and explicitly empty
22
+ rows is the same complete empty result on those two TRs. Live clients and
23
+ other TRs still reject it.
12
24
  """
25
+ if response.status_code != 200 or response.error_msg:
26
+ raise ValueError(f"{tr} did not return a complete terminal response")
27
+ code = response.rsp_cd
28
+ accepted = code in {"00000", "00136", "00707"} or (
29
+ paper_trading is True
30
+ and tr in _NO_DATA_TRS
31
+ and code == "02679"
32
+ and not response.block2
33
+ and response.rsp_msg in PAPER_FUTURES_NO_RESULT_MESSAGES
34
+ )
35
+ if not accepted:
36
+ raise ValueError(unverified_response_code(tr, code, response.rsp_msg))
13
37
  if (
14
- response.status_code != 200
15
- or response.error_msg
16
- or response.rsp_cd not in {"00000", "00136", "00707"}
17
- or response.header is None
38
+ response.header is None
18
39
  or response.header.tr_cd not in {tr, "CIDBQ"}
19
40
  or response.header.tr_cont != "N"
20
41
  or response.header.tr_cont_key
@@ -29,9 +50,7 @@ def require_complete_rows(response, request, tr):
29
50
  or any(getattr(echo, field) != value for field, value in expected.items())
30
51
  ):
31
52
  raise ValueError(f"{tr} query echo is missing or mismatched")
32
- if response.rsp_cd == "00707" and (
33
- tr not in {"CIDBQ01500", "CIDBQ02400"} or response.block2
34
- ):
53
+ if code == "00707" and (tr not in _NO_DATA_TRS or response.block2):
35
54
  raise ValueError(f"{tr} no-data response is inconsistent")
36
55
  return response.block2
37
56
 
@@ -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)
@@ -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.2"
8
+ version = "2.6.4"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -37,7 +37,7 @@ litellm = ">=1.40.0"
37
37
  # (1.26.0 이후로도 context.py 는 WorkflowPnLEvent(**event_data) 에 personal_metrics
38
38
  # 를 무조건 실으므로 그 하한 근거도 이 상한에 포함된다.)
39
39
  programgarden-core = "^2.6.2"
40
- programgarden-finance = "^2.0.1"
40
+ programgarden-finance = "^2.0.2"
41
41
  programgarden-community = "^2.3.1"
42
42
 
43
43
  [tool.poetry.group.dev.dependencies]