programgarden 2.7.1__tar.gz → 2.7.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 (76) hide show
  1. {programgarden-2.7.1 → programgarden-2.7.3}/PKG-INFO +1 -1
  2. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/context.py +9 -0
  3. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/broker_snapshot.py +9 -2
  4. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/position_reconciliation.py +82 -21
  5. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/executor.py +85 -25
  6. programgarden-2.7.3/programgarden/paper_futures_business_date.py +355 -0
  7. {programgarden-2.7.1 → programgarden-2.7.3}/pyproject.toml +1 -1
  8. programgarden-2.7.1/programgarden/paper_futures_business_date.py +0 -96
  9. {programgarden-2.7.1 → programgarden-2.7.3}/README.md +0 -0
  10. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/__init__.py +0 -0
  11. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/binding_provenance.py +0 -0
  12. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/binding_validator.py +0 -0
  13. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/client.py +0 -0
  14. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/code_worker.py +0 -0
  15. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/__init__.py +0 -0
  16. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/adjustment_delivery.py +0 -0
  17. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/broker_evidence.py +0 -0
  18. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/broker_order_totals.py +0 -0
  19. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/checkpoint_manager.py +0 -0
  20. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/db_naming.py +0 -0
  21. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/execution_storage.py +0 -0
  22. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/fill_reconciler.py +0 -0
  23. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/order_cancellation.py +0 -0
  24. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/order_recovery.py +0 -0
  25. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/owned_order_cancellation.py +0 -0
  26. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/query_builder.py +0 -0
  27. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/sqlite_scope.py +0 -0
  28. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/workflow_position_tracker.py +0 -0
  29. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/database/workflow_risk_tracker.py +0 -0
  30. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/deep_fixtures.py +0 -0
  31. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_contract_info.py +0 -0
  32. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_exchange.py +0 -0
  33. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_history.py +0 -0
  34. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_orderable.py +0 -0
  35. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_pnl.py +0 -0
  36. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/futures_read_evidence.py +0 -0
  37. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/incremental_build.py +0 -0
  38. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/managed_order_control.py +0 -0
  39. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/node_runner.py +0 -0
  40. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/order_lifecycle.py +0 -0
  41. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/plugin/__init__.py +0 -0
  42. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/plugin/sandbox.py +0 -0
  43. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/providers/__init__.py +0 -0
  44. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/providers/llm_errors.py +0 -0
  45. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/providers/llm_provider.py +0 -0
  46. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/ranking_nodes.py +0 -0
  47. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/reconnect_handler.py +0 -0
  48. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_contracts.py +0 -0
  49. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_events.py +0 -0
  50. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_external.py +0 -0
  51. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_order_adapter.py +0 -0
  52. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_orders.py +0 -0
  53. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_responders.py +0 -0
  54. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_scenarios.py +0 -0
  55. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_semantics.py +0 -0
  56. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_sources.py +0 -0
  57. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_sqlite.py +0 -0
  58. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_tape.py +0 -0
  59. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/replay_triggers.py +0 -0
  60. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resolver.py +0 -0
  61. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resource/__init__.py +0 -0
  62. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resource/context.py +0 -0
  63. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resource/limiter.py +0 -0
  64. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resource/monitor.py +0 -0
  65. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/resource/throttle.py +0 -0
  66. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/semantic_rules.py +0 -0
  67. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/__init__.py +0 -0
  68. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/credential_tools.py +0 -0
  69. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/definition_tools.py +0 -0
  70. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/event_tools.py +0 -0
  71. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/job_tools.py +0 -0
  72. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/registry_tools.py +0 -0
  73. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/tools/sqlite_tools.py +0 -0
  74. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/validation_recommender.py +0 -0
  75. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/validation_replay.py +0 -0
  76. {programgarden-2.7.1 → programgarden-2.7.3}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.7.1
3
+ Version: 2.7.3
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -209,6 +209,8 @@ class ExecutionContext:
209
209
  )
210
210
  self._startup_reconciled = False
211
211
  self._startup_broker_snapshot = None
212
+ # Paper futures "no business date" waiting notice (informational only).
213
+ self._business_day_notice = None
212
214
  self.position_adjustments: List[Dict[str, Any]] = []
213
215
  self.context_params = context_params or {}
214
216
 
@@ -1886,6 +1888,13 @@ class ExecutionContext:
1886
1888
  except Exception as e:
1887
1889
  logger.warning(f"Listener error on_risk_event: {e}")
1888
1890
 
1891
+ # Informational broker facts (the paper futures waiting notice) are not
1892
+ # risk alerts: screens get them through on_risk_event above, but they
1893
+ # never become a RISK_ALERT notification (telegram/kakao/AI consumers).
1894
+ from programgarden.paper_futures_business_date import INFORMATIONAL_BROKER_EVENT_TYPES
1895
+ if event.event_type in INFORMATIONAL_BROKER_EVENT_TYPES:
1896
+ return
1897
+
1889
1898
  # RISK_ALERT 자동 래핑: risk_event → notification
1890
1899
  try:
1891
1900
  risk_sev = NotificationSeverity.CRITICAL if event.severity == "critical" else (
@@ -1,9 +1,10 @@
1
1
  """Read scoped startup evidence using existing SDK example query parameters."""
2
2
 
3
3
  import asyncio
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from datetime import datetime, timedelta, timezone
6
6
  from decimal import Decimal
7
+ from typing import Any, Optional
7
8
 
8
9
  from .broker_evidence import checked_body, StartupQueryDateMismatch
9
10
  from .position_reconciliation import (
@@ -15,6 +16,10 @@ from .position_reconciliation import (
15
16
  class BrokerStartupSnapshot:
16
17
  positions: VerifiedPositionSnapshot
17
18
  pending_orders: tuple[dict, ...]
19
+ # Paper overseas futures only: the validated CIDBQ01500 observation
20
+ # (PaperFuturesBusinessDate; ``day`` is "" when the server gave no business
21
+ # date). None for live accounts and other products.
22
+ paper_business_date: Optional[Any] = field(default=None, repr=False, compare=False)
18
23
 
19
24
 
20
25
  def required(row, field):
@@ -57,6 +62,7 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
57
62
  observed_at = datetime.now(timezone.utc)
58
63
  day = observed_at.astimezone(timezone(timedelta(hours=9))).strftime("%Y%m%d")
59
64
  positions, pending = {}, []
65
+ paper_day = None
60
66
  if product == "korea_stock":
61
67
  from programgarden_finance.ls.korea_stock.extension.valuation import collect_domestic_trading_positions
62
68
  from programgarden_finance.ls.korea_stock.extension.account_queries import collect_domestic_open_orders
@@ -156,4 +162,5 @@ async def read_broker_snapshot(ls, *, execution_key, product, provider, trading_
156
162
  raise ReconciliationUnavailable("ambiguous_pending_orders")
157
163
  return BrokerStartupSnapshot(
158
164
  VerifiedPositionSnapshot(execution_key, product, provider, trading_mode, observed_at,
159
- positions, source, True, True, tuple(pending)), tuple(pending))
165
+ positions, source, True, True, tuple(pending)), tuple(pending),
166
+ paper_business_date=paper_day)
@@ -81,6 +81,27 @@ def assert_order_evidence(conn, tracker, snapshot):
81
81
  whole quantity. Absence from a pending list does not prove cancellation.
82
82
  In particular a partially filled order must not disappear from this check
83
83
  merely because one trade_history row already exists.
84
+
85
+ Order-date matching mirrors the fill tracker. The tracker records a fill
86
+ under *its own* execution date and associates it with an order whose
87
+ recorded date differs by at most one day (``_order_date_window``): a
88
+ futures order placed on D fills on D+1 at the paper night session and the
89
+ 07:00 KST business-day roll, and an overseas-stock order straddling KST
90
+ midnight fills on D-1. Matching the recorded fills to the order on the
91
+ exact ``order_date`` alone counted those real fills as missing, so a
92
+ futures run — which has no missing-fill recovery path — stayed held at the
93
+ broker node on every restart forever (``owned_fills_require_reconciliation``).
94
+
95
+ This now claims exact-date fills first across every order, then widens to
96
+ the same D-1/D+1 window. The window is a heuristic (the daily-reset order
97
+ number can be reissued on an adjacent day), so a windowed fill is counted
98
+ only when its symbol, side and workflow classification match the order and
99
+ it carries — or is a known legacy of — a stable execution identity; a
100
+ mismatch is skipped, not raised, because it is a different order that merely
101
+ reused the number. A consumed-id set spans the whole loop, so one fill is
102
+ evidence for at most one order even when adjacent-day orders share a number.
103
+ An exact-date fill keeps the strict identity guard it always had. Fill
104
+ storage is unchanged.
84
105
  """
85
106
  pending = {}
86
107
  for row in snapshot.pending_orders:
@@ -100,43 +121,83 @@ def assert_order_evidence(conn, tracker, snapshot):
100
121
  SELECT order_date, order_no, symbol, side, quantity FROM workflow_orders
101
122
  WHERE product=? AND provider=? AND trading_mode=?
102
123
  """, scope).fetchall()
124
+ from .order_recovery import recovery_for_order
125
+ from .order_cancellation import cancellation_quantity
126
+
127
+ entries = []
103
128
  for day, number, symbol, side, raw_qty in orders:
104
- key = day, str(number).lstrip("0")
105
- fills = conn.execute("""
129
+ norm_number = str(number).lstrip("0")
130
+ recovery = recovery_for_order(conn, tracker, day, norm_number)
131
+ entries.append({
132
+ "day": day,
133
+ "number": norm_number,
134
+ "symbol": normalized_symbol(symbol, tracker.product),
135
+ "side": side,
136
+ "raw_qty": raw_qty,
137
+ "recovery": recovery,
138
+ "legacy_ids": set(json.loads(recovery["legacy_fill_ids"])) if recovery else set(),
139
+ "total": Decimal(0),
140
+ })
141
+
142
+ def fills_on(dates, number):
143
+ placeholders = ",".join("?" for _ in dates)
144
+ return conn.execute(f"""
106
145
  SELECT symbol, side, quantity, classification, execution_id, id FROM trade_history
107
- WHERE product=? AND provider=? AND trading_mode=? AND order_date=?
108
- AND ltrim(order_no, '0')=?
109
- """, (*scope, *key)).fetchall()
110
- symbol = normalized_symbol(symbol, tracker.product)
111
- total = Decimal(0)
112
- from .order_recovery import recovery_for_order
113
- recovery = recovery_for_order(conn, tracker, *key)
114
- legacy_ids = set(json.loads(recovery["legacy_fill_ids"])) if recovery else set()
115
- for fill_symbol, fill_side, fill_qty, classification, execution_id, fill_id in fills:
116
- if (normalized_symbol(fill_symbol, tracker.product) != symbol or fill_side != side
117
- or classification != "workflow"):
146
+ WHERE product=? AND provider=? AND trading_mode=?
147
+ AND order_date IN ({placeholders}) AND ltrim(order_no, '0')=?
148
+ """, (*scope, *dates, number)).fetchall()
149
+
150
+ consumed: set = set()
151
+
152
+ # Pass 1 — exact order-date fills, strict identity (behaviour unchanged).
153
+ for e in entries:
154
+ for fill_symbol, fill_side, fill_qty, classification, execution_id, fill_id in \
155
+ fills_on([e["day"]], e["number"]):
156
+ if (normalized_symbol(fill_symbol, tracker.product) != e["symbol"]
157
+ or fill_side != e["side"] or classification != "workflow"):
118
158
  raise ReconciliationUnavailable("ambiguous_order_fill_identity")
119
- if not execution_id and fill_id not in legacy_ids:
159
+ if not execution_id and fill_id not in e["legacy_ids"]:
120
160
  raise ReconciliationUnavailable("historical_execution_identity_unavailable")
121
- total += quantity(fill_qty)
122
- remainder = Decimal(0)
161
+ e["total"] += quantity(fill_qty)
162
+ consumed.add(fill_id)
163
+
164
+ # Pass 2 — the D-1/D+1 window; additive, every per-fill guard a skip condition.
165
+ for e in entries:
166
+ window = tracker._order_date_window(e["day"])
167
+ if not window:
168
+ continue
169
+ for fill_symbol, fill_side, fill_qty, classification, execution_id, fill_id in \
170
+ fills_on(window, e["number"]):
171
+ if fill_id in consumed:
172
+ continue
173
+ if (normalized_symbol(fill_symbol, tracker.product) != e["symbol"]
174
+ or fill_side != e["side"] or classification != "workflow"):
175
+ continue
176
+ if not execution_id and fill_id not in e["legacy_ids"]:
177
+ continue
178
+ e["total"] += quantity(fill_qty)
179
+ consumed.add(fill_id)
180
+
181
+ # Per-order terminal evidence: pending, recovery, cancellation, equality.
182
+ for e in entries:
183
+ key = e["day"], e["number"]
184
+ symbol, side, total = e["symbol"], e["side"], e["total"]
123
185
  if key in pending:
124
186
  row = pending[key]
125
187
  if normalized_symbol(row["symbol"], tracker.product) != symbol or row["side"] != side:
126
188
  raise ReconciliationUnavailable("pending_order_identity_mismatch")
127
- remainder = quantity(row["remaining_quantity"])
128
189
  # A still executable order could fill between the two REST reads.
129
190
  # Wait for terminal evidence before changing any owned lots.
130
- if remainder:
191
+ if quantity(row["remaining_quantity"]):
131
192
  raise ReconciliationUnavailable("owned_order_still_pending")
193
+ recovery = e["recovery"]
132
194
  if recovery:
133
195
  if (recovery["symbol"] != symbol or recovery["side"] != side
134
- or quantity(recovery["total_quantity"]) != quantity(raw_qty)):
196
+ or quantity(recovery["total_quantity"]) != quantity(e["raw_qty"])):
135
197
  raise ReconciliationUnavailable("conflicting_recovered_order")
136
198
  total += quantity(recovery["recovered_quantity"])
137
- from .order_cancellation import cancellation_quantity
138
199
  total += cancellation_quantity(conn, tracker, *key, symbol, side)
139
- if total != quantity(raw_qty):
200
+ if total != quantity(e["raw_qty"]):
140
201
  raise ReconciliationUnavailable("owned_fills_require_reconciliation")
141
202
 
142
203
 
@@ -4757,6 +4757,18 @@ class BrokerNodeExecutor(NodeExecutorBase):
4757
4757
  context=context, node_id=node_id, product=product, provider=provider,
4758
4758
  appkey=appkey, appsecret=appsecret, paper_trading=paper_trading,
4759
4759
  )
4760
+ else:
4761
+ # A later cycle: re-check an active "no business date" notice
4762
+ # (at most one read per RECHECK_AFTER_SEC; never fails the node).
4763
+ # It runs in the broker task registry, so the cycle's order
4764
+ # nodes never wait for it and every stop cancels it.
4765
+ from programgarden.paper_futures_business_date import refresh_business_day_notice_in_background
4766
+ try:
4767
+ refresh_business_day_notice_in_background(
4768
+ context, "broker_recheck",
4769
+ lambda coroutine: self._start_background_task(context, coroutine))
4770
+ except Exception as e:
4771
+ logger.warning("Waiting notice refresh not scheduled: %s", type(e).__name__)
4760
4772
 
4761
4773
  # ========================================
4762
4774
  # 위험관리 추적기 초기화 (노드/플러그인 risk feature 수집)
@@ -4913,6 +4925,10 @@ class BrokerNodeExecutor(NodeExecutorBase):
4913
4925
  details={"adjustments": adjustments, "source": snapshot.positions.source,
4914
4926
  "reason": "broker_quantity_reduction", "is_trade": False},
4915
4927
  ))
4928
+ if snapshot.paper_business_date is not None:
4929
+ from programgarden.paper_futures_business_date import observe_business_day
4930
+ await observe_business_day(context, snapshot.paper_business_date, source="startup_check",
4931
+ ls=ls, broker_node_id=node_id)
4916
4932
 
4917
4933
  def _collect_risk_features(self, context: ExecutionContext) -> set:
4918
4934
  """워크플로우 내 노드/플러그인의 risk feature 요구사항 수집.
@@ -6802,7 +6818,8 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
6802
6818
  context.log("info", f"{node_type}: provider={provider}, product={product}", node_id)
6803
6819
 
6804
6820
  if provider == "ls-sec.co.kr":
6805
- return await self._execute_ls(node_id, node_type, product, context)
6821
+ return await self._execute_ls(node_id, node_type, product, context,
6822
+ broker_node_id=broker_connection.get("broker_node_id"))
6806
6823
  else:
6807
6824
  context.log("error", f"Unsupported provider: {provider}", node_id)
6808
6825
  return self._empty_result(f"Unsupported provider: {provider}")
@@ -6813,6 +6830,7 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
6813
6830
  node_type: str,
6814
6831
  product: str,
6815
6832
  context: ExecutionContext,
6833
+ broker_node_id: Optional[str] = None,
6816
6834
  ) -> Dict[str, Any]:
6817
6835
  """LS증권 미체결 조회"""
6818
6836
  credential = context.get_credential()
@@ -6840,7 +6858,7 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
6840
6858
  if product == "overseas_stock":
6841
6859
  return await self._ls_overseas_stock(ls, node_id, context)
6842
6860
  elif product in ("overseas_futures", "overseas_futureoption"):
6843
- return await self._ls_overseas_futures(ls, node_id, context)
6861
+ return await self._ls_overseas_futures(ls, node_id, context, broker_node_id=broker_node_id)
6844
6862
  elif product == "korea_stock":
6845
6863
  return await self._ls_korea_stock(ls, node_id, context)
6846
6864
  else:
@@ -7003,13 +7021,16 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
7003
7021
  "count": len(open_orders),
7004
7022
  }
7005
7023
 
7006
- async def _ls_overseas_futures(self, ls, node_id: str, context: ExecutionContext) -> Dict[str, Any]:
7024
+ async def _ls_overseas_futures(self, ls, node_id: str, context: ExecutionContext,
7025
+ broker_node_id: Optional[str] = None) -> Dict[str, Any]:
7007
7026
  """해외선물 미체결 조회 (CIDBQ02400)"""
7008
7027
  from datetime import datetime
7009
7028
  from zoneinfo import ZoneInfo
7010
7029
  from programgarden.futures_exchange import resolve_contract_exchanges
7011
7030
  from programgarden.futures_read_evidence import require_complete_rows, require_pending_identity
7012
- from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
7031
+ from programgarden.paper_futures_business_date import (
7032
+ is_paper_client, observe_business_day, read_current_business_date,
7033
+ )
7013
7034
  from programgarden.database.broker_evidence import StartupEvidenceUnavailable
7014
7035
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import CIDBQ02400InBlock1
7015
7036
 
@@ -7026,6 +7047,7 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
7026
7047
  ThdayTpCode="1", OrdStatCode="2", BnsTpCode="0", QryTpCode="2",
7027
7048
  OrdPtnCode="00", OvrsDrvtFnoTpCode="A",
7028
7049
  )
7050
+ inquiry_error = None
7029
7051
  try:
7030
7052
  if paper_day:
7031
7053
  paper_day.scope.check(ls)
@@ -7036,7 +7058,18 @@ class OpenOrdersNodeExecutor(NodeExecutorBase):
7036
7058
  for row in rows:
7037
7059
  require_pending_identity(row)
7038
7060
  except (ValueError, StartupEvidenceUnavailable) as exc:
7039
- return self._empty_result(str(exc))
7061
+ inquiry_error = str(exc)
7062
+ except Exception:
7063
+ await observe_business_day(context, paper_day, source="open_orders", ls=ls,
7064
+ broker_node_id=broker_node_id)
7065
+ raise
7066
+ # The positions answer itself is the evidence; no extra call. Listeners
7067
+ # hear it only once the order inquiry is over, so their delivery time
7068
+ # never counts against the inquiry's 60 s observation window.
7069
+ await observe_business_day(context, paper_day, source="open_orders", ls=ls,
7070
+ broker_node_id=broker_node_id)
7071
+ if inquiry_error is not None:
7072
+ return self._empty_result(inquiry_error)
7040
7073
 
7041
7074
  open_orders = []
7042
7075
  for item in rows:
@@ -17361,33 +17394,43 @@ class NewOrderNodeExecutor(NodeExecutorBase):
17361
17394
  from programgarden_finance.ls.overseas_futureoption.accno.CIDBQ02400.blocks import (
17362
17395
  CIDBQ02400InBlock1,
17363
17396
  )
17364
- from programgarden.paper_futures_business_date import is_paper_client, read_current_business_date
17397
+ from programgarden.paper_futures_business_date import (
17398
+ is_paper_client, observe_business_day, read_current_business_date,
17399
+ )
17365
17400
 
17366
17401
  # A paper server can reject the documented blank same-day dates.
17367
17402
  # Use its fresh account-scoped date, including overnight sessions;
17368
17403
  # never replace missing evidence with the machine's calendar date.
17369
17404
  paper_day = await read_current_business_date(ls) if is_paper_client(ls) else None
17370
17405
  query_day = paper_day.day if paper_day else ""
17371
- if paper_day:
17372
- paper_day.scope.check(ls)
17373
- query = ls.overseas_futureoption().accno().CIDBQ02400(
17374
- body=CIDBQ02400InBlock1(
17375
- RecCnt=1,
17376
- IsuCodeVal="", # 전체 종목
17377
- QrySrtDt=query_day,
17378
- QryEndDt=query_day,
17379
- ThdayTpCode="1", # 1: 당일조회
17380
- OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
17381
- BnsTpCode="0", # 0: 전체
17382
- QryTpCode="1", # 1: 역순(방금 낸 주문이 앞쪽)
17383
- OrdPtnCode="00", # 00: 전체
17384
- OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
17406
+ try:
17407
+ if paper_day:
17408
+ paper_day.scope.check(ls)
17409
+ query = ls.overseas_futureoption().accno().CIDBQ02400(
17410
+ body=CIDBQ02400InBlock1(
17411
+ RecCnt=1,
17412
+ IsuCodeVal="", # 전체 종목
17413
+ QrySrtDt=query_day,
17414
+ QryEndDt=query_day,
17415
+ ThdayTpCode="1", # 1: 당일조회
17416
+ OrdStatCode="0", # 0: 전체(체결+미체결) — 상태 판정에 필요
17417
+ BnsTpCode="0", # 0: 전체
17418
+ QryTpCode="1", # 1: 역순(방금 낸 주문이 앞쪽)
17419
+ OrdPtnCode="00", # 00: 전체
17420
+ OvrsDrvtFnoTpCode="A", # A: 전체(선물+옵션)
17421
+ )
17385
17422
  )
17386
- )
17387
- response = await query.req_async()
17423
+ response = await query.req_async()
17388
17424
 
17389
- if paper_day:
17390
- await paper_day.check_orders(ls, response, query)
17425
+ if paper_day:
17426
+ await paper_day.check_orders(ls, response, query)
17427
+ except Exception:
17428
+ await observe_business_day(context, paper_day, source="fill_observation", ls=ls)
17429
+ raise
17430
+ # The positions answer is the evidence (no extra call). Listeners
17431
+ # hear it only once the order inquiry is over, so their delivery
17432
+ # time never counts against the inquiry's 60 s observation window.
17433
+ await observe_business_day(context, paper_day, source="fill_observation", ls=ls)
17391
17434
 
17392
17435
  if getattr(response, "error_msg", None):
17393
17436
  context.log("debug", f"CIDBQ02400 fill query error: {response.error_msg}", node_id)
@@ -24482,6 +24525,21 @@ class WorkflowJob:
24482
24525
  rate_limit_state["executing_count"] = max(0, current_count - 1)
24483
24526
  self.context.set_node_state(node_id, rate_limit_state_key, rate_limit_state)
24484
24527
 
24528
+ def _refresh_business_day_notice_in_background(self, source: str) -> None:
24529
+ """Re-check an active paper futures waiting notice without delaying this event.
24530
+
24531
+ The read is owned by the broker task registry, so every termination
24532
+ path cancels it. Nothing starts unless the notice is active and due.
24533
+ """
24534
+ from programgarden.paper_futures_business_date import refresh_business_day_notice_in_background
24535
+
24536
+ try:
24537
+ refresh_business_day_notice_in_background(
24538
+ self.context, source,
24539
+ lambda coroutine: BrokerNodeExecutor()._start_background_task(self.context, coroutine))
24540
+ except Exception as e: # Never let the notice disturb realtime handling.
24541
+ logger.warning("Waiting notice refresh not scheduled: %s", type(e).__name__)
24542
+
24485
24543
  async def _handle_realtime_update(self, event: WorkflowEvent) -> None:
24486
24544
  """
24487
24545
  Handle realtime update event
@@ -24501,7 +24559,9 @@ class WorkflowJob:
24501
24559
  if not trigger_nodes:
24502
24560
  _safe_print(f" ⚠️ 트리거할 노드 없음, 스킵")
24503
24561
  return
24504
-
24562
+
24563
+ self._refresh_business_day_notice_in_background("realtime_recheck")
24564
+
24505
24565
  logger.debug(f"Realtime update from {event.source_node_id}, triggering: {trigger_nodes}")
24506
24566
  _safe_print(f" → {event.source_node_id}에서 트리거: {trigger_nodes}")
24507
24567
 
@@ -0,0 +1,355 @@
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
+ When the paper server returns no business date at all (observed on weekends,
8
+ when its overseas futures day is closed), the order inquiry uses the published
9
+ blank-date same-day form instead, with the same response checks. This is the
10
+ form the server startup gate already uses for that answer, so a start the
11
+ server admits is not refused here.
12
+
13
+ That answer is not an error, but the run then waits for the market to open.
14
+ ``BusinessDayNotice`` tells the run's listeners so through an informational
15
+ ``RiskEvent`` (``broker_business_day_unavailable``), and clears it
16
+ (``broker_business_day_available``) as soon as a later read carries a date.
17
+ Its details never carry a date, an account or key material.
18
+ """
19
+ import asyncio
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ import hashlib
23
+ import logging
24
+ import re
25
+ import time
26
+
27
+ from programgarden.database.broker_evidence import StartupEvidenceUnavailable, broker_message, checked_body
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _RESPONSE_CODE_REASON = "CIDBQ02400: unverified_response_code="
32
+
33
+
34
+ def broker_business_day(value):
35
+ """The broker's explicit YYYYMMDD business date, or "" when none was given.
36
+
37
+ A blank, missing, padded or impossible value is "no date". It is never
38
+ replaced by a local, previous or cached date.
39
+ """
40
+ if not isinstance(value, str) or not re.fullmatch(r"[0-9]{8}", value):
41
+ return ""
42
+ try:
43
+ datetime.strptime(value, "%Y%m%d")
44
+ except ValueError:
45
+ return ""
46
+ return value
47
+
48
+
49
+ def blank_date_unverified(response):
50
+ """No business date, and the blank-date answer's code was not accepted."""
51
+ return (f"CIDBQ02400: blank_date_unverified rsp_cd={getattr(response, 'rsp_cd', None)} "
52
+ f"rsp_msg={broker_message(getattr(response, 'rsp_msg', None))}")
53
+
54
+
55
+ def is_paper_client(ls):
56
+ return getattr(getattr(ls, "token_manager", None), "paper_trading", None) is True
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class PaperFuturesScope:
61
+ manager: object = field(repr=False)
62
+ key_digest: str = field(repr=False)
63
+ started: float
64
+
65
+ @classmethod
66
+ def capture(cls, ls):
67
+ manager = getattr(ls, "token_manager", None)
68
+ key = getattr(manager, "appkey", None)
69
+ if not is_paper_client(ls) or not isinstance(key, str) or not key:
70
+ raise StartupEvidenceUnavailable("paper_futures_business_date: unavailable_client_scope")
71
+ return cls(manager, hashlib.sha256(key.encode()).hexdigest(), time.monotonic())
72
+
73
+ def check(self, ls):
74
+ current = self.capture(ls)
75
+ if current.manager is not self.manager or current.key_digest != self.key_digest:
76
+ raise StartupEvidenceUnavailable("paper_futures_business_date: client_scope_changed")
77
+ age = time.monotonic() - self.started
78
+ if age < 0 or age > 60:
79
+ raise StartupEvidenceUnavailable("paper_futures_business_date: expired_observation")
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class PaperFuturesBusinessDate:
84
+ """One fresh CIDBQ01500 observation for the following CIDBQ02400 read.
85
+
86
+ ``day`` is the broker's explicit business date, or "" when the paper server
87
+ returned none; "" selects the published blank-date same-day query (both
88
+ dates blank). ``account`` is the positions echo; it is always present with
89
+ a date and may be "" only on a blank-date answer.
90
+ """
91
+ day: str
92
+ account: str = field(repr=False)
93
+ scope: PaperFuturesScope = field(repr=False)
94
+
95
+ @property
96
+ def blank_date(self):
97
+ return not self.day
98
+
99
+ async def check_orders(self, ls, response, query):
100
+ self.scope.check(ls)
101
+ try:
102
+ body = await checked_body(response, "CIDBQ02400", query, paper_trading=True)
103
+ except StartupEvidenceUnavailable as exc:
104
+ # Without a business date the published blank-date form is the only
105
+ # query left; name that case, with the broker's answer, when its
106
+ # response code is not accepted. Other failures keep their reasons.
107
+ if self.blank_date and str(exc).startswith(_RESPONSE_CODE_REASON):
108
+ raise StartupEvidenceUnavailable(blank_date_unverified(response)) from None
109
+ raise
110
+ echo = body["CIDBQ02400OutBlock1"]
111
+ request = next(iter(query.request_data.body.values()))
112
+ expected = request.model_dump(exclude={"RecCnt"})
113
+ # A dated observation always carries its account; a blank-date one binds
114
+ # the account only when LS echoed it (the credential scope check below
115
+ # still proves both reads used the same token, key and paper mode).
116
+ if (request.ThdayTpCode != "1" or request.QrySrtDt != self.day
117
+ or request.QryEndDt != self.day or not isinstance(echo, dict)
118
+ or (self.account and echo.get("AcntNo") != self.account)
119
+ or any(name not in echo or echo[name] != value for name, value in expected.items())):
120
+ raise StartupEvidenceUnavailable("CIDBQ02400: business_date_scope_mismatch")
121
+ self.scope.check(ls)
122
+ return response.block2
123
+
124
+
125
+ async def from_current_positions(ls, response, query, scope):
126
+ """Validate a just-fetched positions response without making another call."""
127
+ scope.check(ls)
128
+ body = await checked_body(response, "CIDBQ01500", query, paper_trading=True)
129
+ echo = body["CIDBQ01500OutBlock1"]
130
+ request = next(iter(query.request_data.body.values()))
131
+ expected = {"AcntTpCode": "1", "QryDt": "", "BalTpCode": "2", "FcmAcntNo": ""}
132
+ if any(getattr(request, name) != value for name, value in expected.items()):
133
+ raise StartupEvidenceUnavailable("CIDBQ01500: current_date_query_required")
134
+ if not isinstance(echo, dict) or any(echo.get(name) != value for name, value in expected.items() if name != "QryDt"):
135
+ raise StartupEvidenceUnavailable("CIDBQ01500: business_date_scope_mismatch")
136
+ day, account = broker_business_day(echo.get("QryDt")), echo.get("AcntNo")
137
+ if not isinstance(account, str) or not account.strip():
138
+ if day:
139
+ raise StartupEvidenceUnavailable("CIDBQ01500: unavailable_account_echo")
140
+ account = ""
141
+ if not day:
142
+ # Weekend paper answers carry no business date. The server startup gate
143
+ # uses the blank-date order form for this answer; so does the engine.
144
+ logger.info("CIDBQ01500: paper server returned no business date; "
145
+ "using the published blank-date CIDBQ02400 same-day form")
146
+ scope.check(ls)
147
+ return PaperFuturesBusinessDate(day, account, scope)
148
+
149
+
150
+ async def read_current_business_date(ls):
151
+ """One bounded read; the SDK retains its existing shared-key rate limiter."""
152
+ from programgarden_finance import CIDBQ01500
153
+
154
+ scope = PaperFuturesScope.capture(ls)
155
+ query = ls.overseas_futureoption().accno().CIDBQ01500(body=CIDBQ01500.CIDBQ01500InBlock1(
156
+ RecCnt=1, AcntTpCode="1", FcmAcntNo="", QryDt="", BalTpCode="2"))
157
+ response = await query.req_async()
158
+ return await from_current_positions(ls, response, query, scope)
159
+
160
+
161
+ # --- Waiting notice: the paper server gave no business date -------------------
162
+ #
163
+ # A blank CIDBQ01500 QryDt is not an error (see above), but the run then has no
164
+ # broker business day: on a weekend or before the open nothing trades until the
165
+ # market opens. Hosts forward RiskEvent to their screens unchanged, so the
166
+ # notice uses that existing event with severity "info" and two event types.
167
+ # It is never a failure, never halts or releases an order, and its details never
168
+ # carry a date or time (not the missing business date, not a local or previous
169
+ # one), an account echo or key material; hosts stamp their own receive time.
170
+
171
+ BUSINESS_DAY_UNAVAILABLE = "broker_business_day_unavailable"
172
+ BUSINESS_DAY_AVAILABLE = "broker_business_day_available"
173
+ INFORMATIONAL_BROKER_EVENT_TYPES = frozenset({BUSINESS_DAY_UNAVAILABLE, BUSINESS_DAY_AVAILABLE})
174
+ MARKET_OPEN_WAIT_NOTICE = "market_open_wait"
175
+ # At most one extra CIDBQ01500 read per this many seconds while the notice is
176
+ # active, and only when the run is doing work (a broker node cycle or a
177
+ # realtime event). Nothing is read on a quiet weekend.
178
+ RECHECK_AFTER_SEC = 300
179
+ # A still-active notice is repeated at most this often, for screens that join
180
+ # while the run waits. No broker read is made for a repeat.
181
+ REPEAT_AFTER_SEC = 60
182
+ RECHECK_TIMEOUT_SEC = 30
183
+
184
+
185
+ class BusinessDayNotice:
186
+ """Per-run state of the waiting notice (one ExecutionContext).
187
+
188
+ Observations are applied in the order their reads started, so a slower
189
+ read that began before a newer answer cannot undo it. A re-check that is
190
+ refused or fails keeps the current state and sends nothing.
191
+ """
192
+
193
+ def __init__(self):
194
+ self.active = False
195
+ self.broker_node_id = None
196
+ self._ls = None
197
+ self._last_read = None
198
+ self._last_applied_read = None
199
+ self._last_sent = None
200
+ self._in_flight = False
201
+ self._background = None
202
+
203
+ def due(self, now=None):
204
+ """Active and no read has started for RECHECK_AFTER_SEC seconds."""
205
+ now = time.monotonic() if now is None else now
206
+ return (self.active and not self._in_flight and self._ls is not None
207
+ and (self._last_read is None or now - self._last_read >= RECHECK_AFTER_SEC))
208
+
209
+ def _repeat_due(self, now):
210
+ return self._last_sent is not None and now - self._last_sent >= REPEAT_AFTER_SEC
211
+
212
+ def needs_refresh(self, now=None):
213
+ """A re-check or a repeat is due (cheap; no broker call)."""
214
+ now = time.monotonic() if now is None else now
215
+ return self.active and not self._in_flight and (self.due(now) or self._repeat_due(now))
216
+
217
+ async def observe(self, context, paper_day, *, source, ls, broker_node_id=None):
218
+ """Apply one validated CIDBQ01500 observation (no extra call)."""
219
+ if paper_day is None:
220
+ return
221
+ started = paper_day.scope.started
222
+ if self._last_applied_read is not None and started < self._last_applied_read:
223
+ return
224
+ self._last_applied_read = started
225
+ self._last_read = started if self._last_read is None else max(self._last_read, started)
226
+ if broker_node_id and not self.broker_node_id:
227
+ self.broker_node_id = broker_node_id
228
+ if paper_day.day:
229
+ if self.active:
230
+ self.active, self._ls = False, None
231
+ logger.info("CIDBQ01500: paper server returned a business date again; waiting notice cleared")
232
+ await self._send(context, active=False, source=source)
233
+ return
234
+ self._ls = ls
235
+ if not self.active:
236
+ self.active = True
237
+ await self._send(context, active=True, source=source)
238
+ elif self._last_sent is None or self._repeat_due(time.monotonic()):
239
+ await self._send(context, active=True, source=source)
240
+
241
+ async def refresh(self, context, source):
242
+ """At a work moment: re-read when due, else repeat a still-active notice."""
243
+ if not self.active or self._in_flight:
244
+ return
245
+ now = time.monotonic()
246
+ if self.due(now):
247
+ await self.recheck(context, source)
248
+ elif self._repeat_due(now):
249
+ await self._send(context, active=True, source=source)
250
+
251
+ async def recheck(self, context, source):
252
+ """One bounded CIDBQ01500 read on the run's existing client."""
253
+ ls = self._ls
254
+ if not self.active or self._in_flight or ls is None or not is_paper_client(ls):
255
+ return
256
+ if not getattr(context, "is_running", True) or getattr(context, "is_shutdown", False):
257
+ return
258
+ self._in_flight = True
259
+ self._last_read = time.monotonic()
260
+ try:
261
+ paper_day = await asyncio.wait_for(read_current_business_date(ls), timeout=RECHECK_TIMEOUT_SEC)
262
+ except asyncio.CancelledError:
263
+ raise
264
+ except Exception as exc:
265
+ # Refused, partial or unreachable: keep the notice, try again later.
266
+ logger.info("CIDBQ01500 business-date re-check unavailable (%s); waiting notice kept",
267
+ type(exc).__name__)
268
+ return
269
+ finally:
270
+ self._in_flight = False
271
+ await self.observe(context, paper_day, source=source, ls=ls)
272
+
273
+ async def _send(self, context, *, active, source):
274
+ from programgarden_core.bases.listener import RiskEvent
275
+
276
+ self._last_sent = time.monotonic()
277
+ notify = getattr(context, "notify_risk_event", None)
278
+ if notify is None:
279
+ return
280
+ try:
281
+ await notify(RiskEvent(
282
+ job_id=getattr(context, "job_id", None),
283
+ event_type=BUSINESS_DAY_UNAVAILABLE if active else BUSINESS_DAY_AVAILABLE,
284
+ severity="info",
285
+ action_hint="advisory",
286
+ details={
287
+ "notice": MARKET_OPEN_WAIT_NOTICE,
288
+ "active": active,
289
+ "broker_node_id": self.broker_node_id,
290
+ "product": "overseas_futures",
291
+ "trading_mode": "paper",
292
+ "source": source,
293
+ "evidence": "CIDBQ01500.QryDt",
294
+ "recheck_after_sec": RECHECK_AFTER_SEC,
295
+ "is_error": False,
296
+ "is_trade": False,
297
+ },
298
+ ))
299
+ except Exception as exc: # A listener problem never fails the run.
300
+ logger.warning("Waiting notice delivery failed: %s", type(exc).__name__)
301
+
302
+
303
+ def business_day_notice(context):
304
+ """This run's notice, or None where none may be sent.
305
+
306
+ dry_run, deep_validate and replay validation (a dry_run) never send it, and
307
+ an object that is not an execution context (a bare test double) has none.
308
+ """
309
+ if getattr(context, "is_dry_run", False) or not hasattr(context, "_business_day_notice"):
310
+ return None
311
+ notice = context._business_day_notice
312
+ if notice is None:
313
+ notice = context._business_day_notice = BusinessDayNotice()
314
+ return notice
315
+
316
+
317
+ async def observe_business_day(context, paper_day, *, source, ls, broker_node_id=None):
318
+ """Record a paper observation without ever failing the caller."""
319
+ notice = business_day_notice(context)
320
+ if notice is None or paper_day is None or not is_paper_client(ls):
321
+ return
322
+ try:
323
+ await notice.observe(context, paper_day, source=source, ls=ls, broker_node_id=broker_node_id)
324
+ except Exception as exc:
325
+ logger.warning("Waiting notice update failed: %s", type(exc).__name__)
326
+
327
+
328
+ def refresh_business_day_notice_in_background(context, source, start_task):
329
+ """Schedule one refresh when due, through the caller's task registry.
330
+
331
+ For work moments that must not wait for a broker read (realtime updates
332
+ and later broker-node cycles).
333
+ ``start_task(coroutine)`` returns the task, or None when the run is
334
+ shutting down. At most one refresh is pending at a time.
335
+ """
336
+ notice = business_day_notice(context)
337
+ if notice is None or not notice.needs_refresh():
338
+ return None
339
+ if notice._background is not None and not notice._background.done():
340
+ return None
341
+ notice._background = start_task(refresh_business_day_notice(context, source))
342
+ return notice._background
343
+
344
+
345
+ async def refresh_business_day_notice(context, source):
346
+ """Re-check an active notice at a work moment without ever failing the caller."""
347
+ notice = business_day_notice(context)
348
+ if notice is None or not notice.active:
349
+ return
350
+ try:
351
+ await notice.refresh(context, source)
352
+ except asyncio.CancelledError:
353
+ raise
354
+ except Exception as exc:
355
+ logger.warning("Waiting notice re-check failed: %s", type(exc).__name__)
@@ -5,7 +5,7 @@ authors = [
5
5
  homepage = "https://programgarden.com"
6
6
  requires-python = ">=3.12"
7
7
  name = "programgarden"
8
- version = "2.7.1"
8
+ version = "2.7.3"
9
9
  license = "AGPL-3.0-or-later"
10
10
  description = "ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진"
11
11
  readme = "README.md"
@@ -1,96 +0,0 @@
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)
File without changes