programgarden 2.7.2__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.
- {programgarden-2.7.2 → programgarden-2.7.3}/PKG-INFO +1 -1
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/context.py +9 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/broker_snapshot.py +9 -2
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/position_reconciliation.py +82 -21
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/executor.py +85 -25
- programgarden-2.7.3/programgarden/paper_futures_business_date.py +355 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/pyproject.toml +1 -1
- programgarden-2.7.2/programgarden/paper_futures_business_date.py +0 -151
- {programgarden-2.7.2 → programgarden-2.7.3}/README.md +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/binding_provenance.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/binding_validator.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/client.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/code_worker.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/adjustment_delivery.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/broker_evidence.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/broker_order_totals.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/checkpoint_manager.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/db_naming.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/execution_storage.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/fill_reconciler.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/order_cancellation.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/order_recovery.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/owned_order_cancellation.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/query_builder.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/sqlite_scope.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/workflow_position_tracker.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/workflow_risk_tracker.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/deep_fixtures.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_contract_info.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_exchange.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_history.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_orderable.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_pnl.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/futures_read_evidence.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/incremental_build.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/managed_order_control.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/node_runner.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/order_lifecycle.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/plugin/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/plugin/sandbox.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/providers/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/providers/llm_errors.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/providers/llm_provider.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/ranking_nodes.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/reconnect_handler.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_contracts.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_events.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_external.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_order_adapter.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_orders.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_responders.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_scenarios.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_semantics.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_sources.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_sqlite.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_tape.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/replay_triggers.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resolver.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resource/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resource/context.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resource/limiter.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resource/monitor.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/resource/throttle.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/semantic_rules.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/__init__.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/credential_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/definition_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/event_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/job_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/registry_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/tools/sqlite_tools.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/validation_recommender.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/validation_replay.py +0 -0
- {programgarden-2.7.2 → programgarden-2.7.3}/programgarden/valuation.py +0 -0
|
@@ -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)
|
{programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/position_reconciliation.py
RENAMED
|
@@ -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
|
-
|
|
105
|
-
|
|
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=?
|
|
108
|
-
AND ltrim(order_no, '0')=?
|
|
109
|
-
""", (*scope, *
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for fill_symbol, fill_side, fill_qty, classification, execution_id, fill_id in
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
17372
|
-
paper_day
|
|
17373
|
-
|
|
17374
|
-
|
|
17375
|
-
|
|
17376
|
-
|
|
17377
|
-
|
|
17378
|
-
|
|
17379
|
-
|
|
17380
|
-
|
|
17381
|
-
|
|
17382
|
-
|
|
17383
|
-
|
|
17384
|
-
|
|
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
|
-
|
|
17390
|
-
|
|
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__)
|
|
@@ -1,151 +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
|
-
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
|
-
from dataclasses import dataclass, field
|
|
14
|
-
from datetime import datetime
|
|
15
|
-
import hashlib
|
|
16
|
-
import logging
|
|
17
|
-
import re
|
|
18
|
-
import time
|
|
19
|
-
|
|
20
|
-
from programgarden.database.broker_evidence import StartupEvidenceUnavailable, broker_message, checked_body
|
|
21
|
-
|
|
22
|
-
logger = logging.getLogger(__name__)
|
|
23
|
-
|
|
24
|
-
_RESPONSE_CODE_REASON = "CIDBQ02400: unverified_response_code="
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def broker_business_day(value):
|
|
28
|
-
"""The broker's explicit YYYYMMDD business date, or "" when none was given.
|
|
29
|
-
|
|
30
|
-
A blank, missing, padded or impossible value is "no date". It is never
|
|
31
|
-
replaced by a local, previous or cached date.
|
|
32
|
-
"""
|
|
33
|
-
if not isinstance(value, str) or not re.fullmatch(r"[0-9]{8}", value):
|
|
34
|
-
return ""
|
|
35
|
-
try:
|
|
36
|
-
datetime.strptime(value, "%Y%m%d")
|
|
37
|
-
except ValueError:
|
|
38
|
-
return ""
|
|
39
|
-
return value
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def blank_date_unverified(response):
|
|
43
|
-
"""No business date, and the blank-date answer's code was not accepted."""
|
|
44
|
-
return (f"CIDBQ02400: blank_date_unverified rsp_cd={getattr(response, 'rsp_cd', None)} "
|
|
45
|
-
f"rsp_msg={broker_message(getattr(response, 'rsp_msg', None))}")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def is_paper_client(ls):
|
|
49
|
-
return getattr(getattr(ls, "token_manager", None), "paper_trading", None) is True
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@dataclass(frozen=True)
|
|
53
|
-
class PaperFuturesScope:
|
|
54
|
-
manager: object = field(repr=False)
|
|
55
|
-
key_digest: str = field(repr=False)
|
|
56
|
-
started: float
|
|
57
|
-
|
|
58
|
-
@classmethod
|
|
59
|
-
def capture(cls, ls):
|
|
60
|
-
manager = getattr(ls, "token_manager", None)
|
|
61
|
-
key = getattr(manager, "appkey", None)
|
|
62
|
-
if not is_paper_client(ls) or not isinstance(key, str) or not key:
|
|
63
|
-
raise StartupEvidenceUnavailable("paper_futures_business_date: unavailable_client_scope")
|
|
64
|
-
return cls(manager, hashlib.sha256(key.encode()).hexdigest(), time.monotonic())
|
|
65
|
-
|
|
66
|
-
def check(self, ls):
|
|
67
|
-
current = self.capture(ls)
|
|
68
|
-
if current.manager is not self.manager or current.key_digest != self.key_digest:
|
|
69
|
-
raise StartupEvidenceUnavailable("paper_futures_business_date: client_scope_changed")
|
|
70
|
-
age = time.monotonic() - self.started
|
|
71
|
-
if age < 0 or age > 60:
|
|
72
|
-
raise StartupEvidenceUnavailable("paper_futures_business_date: expired_observation")
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass(frozen=True)
|
|
76
|
-
class PaperFuturesBusinessDate:
|
|
77
|
-
"""One fresh CIDBQ01500 observation for the following CIDBQ02400 read.
|
|
78
|
-
|
|
79
|
-
``day`` is the broker's explicit business date, or "" when the paper server
|
|
80
|
-
returned none; "" selects the published blank-date same-day query (both
|
|
81
|
-
dates blank). ``account`` is the positions echo; it is always present with
|
|
82
|
-
a date and may be "" only on a blank-date answer.
|
|
83
|
-
"""
|
|
84
|
-
day: str
|
|
85
|
-
account: str = field(repr=False)
|
|
86
|
-
scope: PaperFuturesScope = field(repr=False)
|
|
87
|
-
|
|
88
|
-
@property
|
|
89
|
-
def blank_date(self):
|
|
90
|
-
return not self.day
|
|
91
|
-
|
|
92
|
-
async def check_orders(self, ls, response, query):
|
|
93
|
-
self.scope.check(ls)
|
|
94
|
-
try:
|
|
95
|
-
body = await checked_body(response, "CIDBQ02400", query, paper_trading=True)
|
|
96
|
-
except StartupEvidenceUnavailable as exc:
|
|
97
|
-
# Without a business date the published blank-date form is the only
|
|
98
|
-
# query left; name that case, with the broker's answer, when its
|
|
99
|
-
# response code is not accepted. Other failures keep their reasons.
|
|
100
|
-
if self.blank_date and str(exc).startswith(_RESPONSE_CODE_REASON):
|
|
101
|
-
raise StartupEvidenceUnavailable(blank_date_unverified(response)) from None
|
|
102
|
-
raise
|
|
103
|
-
echo = body["CIDBQ02400OutBlock1"]
|
|
104
|
-
request = next(iter(query.request_data.body.values()))
|
|
105
|
-
expected = request.model_dump(exclude={"RecCnt"})
|
|
106
|
-
# A dated observation always carries its account; a blank-date one binds
|
|
107
|
-
# the account only when LS echoed it (the credential scope check below
|
|
108
|
-
# still proves both reads used the same token, key and paper mode).
|
|
109
|
-
if (request.ThdayTpCode != "1" or request.QrySrtDt != self.day
|
|
110
|
-
or request.QryEndDt != self.day or not isinstance(echo, dict)
|
|
111
|
-
or (self.account and echo.get("AcntNo") != self.account)
|
|
112
|
-
or any(name not in echo or echo[name] != value for name, value in expected.items())):
|
|
113
|
-
raise StartupEvidenceUnavailable("CIDBQ02400: business_date_scope_mismatch")
|
|
114
|
-
self.scope.check(ls)
|
|
115
|
-
return response.block2
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
async def from_current_positions(ls, response, query, scope):
|
|
119
|
-
"""Validate a just-fetched positions response without making another call."""
|
|
120
|
-
scope.check(ls)
|
|
121
|
-
body = await checked_body(response, "CIDBQ01500", query, paper_trading=True)
|
|
122
|
-
echo = body["CIDBQ01500OutBlock1"]
|
|
123
|
-
request = next(iter(query.request_data.body.values()))
|
|
124
|
-
expected = {"AcntTpCode": "1", "QryDt": "", "BalTpCode": "2", "FcmAcntNo": ""}
|
|
125
|
-
if any(getattr(request, name) != value for name, value in expected.items()):
|
|
126
|
-
raise StartupEvidenceUnavailable("CIDBQ01500: current_date_query_required")
|
|
127
|
-
if not isinstance(echo, dict) or any(echo.get(name) != value for name, value in expected.items() if name != "QryDt"):
|
|
128
|
-
raise StartupEvidenceUnavailable("CIDBQ01500: business_date_scope_mismatch")
|
|
129
|
-
day, account = broker_business_day(echo.get("QryDt")), echo.get("AcntNo")
|
|
130
|
-
if not isinstance(account, str) or not account.strip():
|
|
131
|
-
if day:
|
|
132
|
-
raise StartupEvidenceUnavailable("CIDBQ01500: unavailable_account_echo")
|
|
133
|
-
account = ""
|
|
134
|
-
if not day:
|
|
135
|
-
# Weekend paper answers carry no business date. The server startup gate
|
|
136
|
-
# uses the blank-date order form for this answer; so does the engine.
|
|
137
|
-
logger.info("CIDBQ01500: paper server returned no business date; "
|
|
138
|
-
"using the published blank-date CIDBQ02400 same-day form")
|
|
139
|
-
scope.check(ls)
|
|
140
|
-
return PaperFuturesBusinessDate(day, account, scope)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
async def read_current_business_date(ls):
|
|
144
|
-
"""One bounded read; the SDK retains its existing shared-key rate limiter."""
|
|
145
|
-
from programgarden_finance import CIDBQ01500
|
|
146
|
-
|
|
147
|
-
scope = PaperFuturesScope.capture(ls)
|
|
148
|
-
query = ls.overseas_futureoption().accno().CIDBQ01500(body=CIDBQ01500.CIDBQ01500InBlock1(
|
|
149
|
-
RecCnt=1, AcntTpCode="1", FcmAcntNo="", QryDt="", BalTpCode="2"))
|
|
150
|
-
response = await query.req_async()
|
|
151
|
-
return await from_current_positions(ls, response, query, scope)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/owned_order_cancellation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{programgarden-2.7.2 → programgarden-2.7.3}/programgarden/database/workflow_position_tracker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|