opencode-pyneruntime 6.6.4__py3-none-any.whl
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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- pynecore/utils/stdlib_checker.py +17 -0
|
@@ -0,0 +1,2655 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified SQLite-backed broker storage.
|
|
3
|
+
|
|
4
|
+
This module replaces the previously split persistence:
|
|
5
|
+
|
|
6
|
+
- the core ``state_store.py`` (append-only JSONL, envelope + parked verifications),
|
|
7
|
+
- plugin-level ledgers (Capital.com ``DealLedger`` etc.).
|
|
8
|
+
|
|
9
|
+
:class:`BrokerStore` writes every broker-relevant piece of state into a
|
|
10
|
+
single SQLite file: the sync-engine envelope identity, parked
|
|
11
|
+
dispatches, the live view of orders, the generic alias table (for
|
|
12
|
+
broker-specific lookup keys) and the structured audit log.
|
|
13
|
+
|
|
14
|
+
Main abstractions of the module:
|
|
15
|
+
|
|
16
|
+
- :class:`RunIdentity` — the run's human-readable logical key
|
|
17
|
+
(``{strategy_id}@{account}:{symbol}:{timeframe}[#label]``).
|
|
18
|
+
Constructed before storage is opened.
|
|
19
|
+
- :class:`RunContext` — context object for a concrete invocation. The
|
|
20
|
+
``run_instance_id`` (physical autoincrement FK) lives here; the
|
|
21
|
+
caller (sync engine, plugin) never sees it.
|
|
22
|
+
- :class:`BrokerStore` — the lifecycle: ``open_run()`` returns a
|
|
23
|
+
``RunContext``, handles stale-run cleanup and schema migration.
|
|
24
|
+
|
|
25
|
+
Two crash-recovery mechanisms run side by side:
|
|
26
|
+
|
|
27
|
+
1. **Passive** — the ``live_runs`` VIEW automatically excludes rows
|
|
28
|
+
whose ``last_heartbeat_ts_ms`` is past the threshold. The dashboard
|
|
29
|
+
always sees the right answer even if physical cleanup has not run
|
|
30
|
+
yet.
|
|
31
|
+
2. **Active** — every ``open_run()`` closes expired rows by setting
|
|
32
|
+
``ended_ts_ms`` and writing a ``stale_run_cleaned`` event.
|
|
33
|
+
|
|
34
|
+
Transactionality: every compound operation (e.g. ``close_order`` =
|
|
35
|
+
update orders + delete order_refs + insert events) runs in a single
|
|
36
|
+
``BEGIN IMMEDIATE ... COMMIT`` block. No half-written state, no
|
|
37
|
+
half-lost log.
|
|
38
|
+
"""
|
|
39
|
+
import contextlib
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import sqlite3
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
from collections.abc import Iterator
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any, Final
|
|
49
|
+
|
|
50
|
+
from pynecore.core.broker.run_identity import RunIdentity
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
'BrokerStore',
|
|
54
|
+
'RunContext',
|
|
55
|
+
'RunIdentity',
|
|
56
|
+
'EnvelopeRecord',
|
|
57
|
+
'PendingRecord',
|
|
58
|
+
'OrderRow',
|
|
59
|
+
'SpotExecutionRow',
|
|
60
|
+
'SpotEpochRow',
|
|
61
|
+
'TransactionRollbackError',
|
|
62
|
+
'HEARTBEAT_INTERVAL_MS',
|
|
63
|
+
'STALE_THRESHOLD_MS',
|
|
64
|
+
'RETENTION_DAYS',
|
|
65
|
+
'PURGE_INTERVAL_MS',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
_log = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
# Heartbeat cadence: a denser ``RunContext.heartbeat()`` call is a no-op.
|
|
71
|
+
HEARTBEAT_INTERVAL_MS: Final[int] = 60_000 # 1 minute
|
|
72
|
+
# A run is considered stale after this much heartbeat silence. The
|
|
73
|
+
# ``live_runs`` VIEW bakes the value into its stored SQL (SQLite cannot
|
|
74
|
+
# parameterise a VIEW); ``_heal_live_runs_view`` recreates the VIEW on
|
|
75
|
+
# open whenever the stored threshold drifts from this constant.
|
|
76
|
+
STALE_THRESHOLD_MS: Final[int] = 5 * HEARTBEAT_INTERVAL_MS # 5 minutes
|
|
77
|
+
# Retention window for historical rows (events, closed orders, ended
|
|
78
|
+
# runs). See :meth:`BrokerStore.cleanup_old_data` for what is protected
|
|
79
|
+
# from purging regardless of age.
|
|
80
|
+
RETENTION_DAYS: Final[int] = 180
|
|
81
|
+
# The purge runs at ``open_run()`` and then at most once per this
|
|
82
|
+
# interval, piggybacking on ``RunContext.heartbeat()`` — a bot that runs
|
|
83
|
+
# for months never revisits ``open_run()``, so the heartbeat path is
|
|
84
|
+
# what keeps the DB bounded while live.
|
|
85
|
+
PURGE_INTERVAL_MS: Final[int] = 24 * 60 * 60 * 1000 # 1 day
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _now_ms() -> int:
|
|
89
|
+
"""Current time in ms, UTC. Centralised so the time import lives in one place."""
|
|
90
|
+
return int(time.time() * 1000)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# === Replay-result dataclasses =============================================
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class EnvelopeRecord:
|
|
97
|
+
"""Replay output for a live envelope.
|
|
98
|
+
|
|
99
|
+
Same shape as the former ``state_store.EnvelopeRecord`` — the sync
|
|
100
|
+
engine consumes it unchanged.
|
|
101
|
+
"""
|
|
102
|
+
key: str
|
|
103
|
+
bar_ts_ms: int
|
|
104
|
+
retry_seq: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class PendingRecord:
|
|
109
|
+
"""Replay output for a parked dispatch.
|
|
110
|
+
|
|
111
|
+
``resolution`` is ``None`` while the dispatch is parked; it flips to
|
|
112
|
+
``'attached'`` or ``'rejected'`` once the plugin decides the outcome
|
|
113
|
+
via a snapshot-recovery path (see
|
|
114
|
+
:meth:`RunContext.record_resolution`). The engine consumes and
|
|
115
|
+
deletes the row on the next
|
|
116
|
+
:meth:`OrderSyncEngine._verify_pending_dispatches` cycle.
|
|
117
|
+
|
|
118
|
+
``dispatch_kind`` distinguishes a parked new dispatch
|
|
119
|
+
(``'new'``: ``execute_entry`` / ``execute_exit`` / ``execute_close``)
|
|
120
|
+
from a parked amend (``'modify'``: ``modify_entry`` /
|
|
121
|
+
``modify_exit``). On a ``'rejected'`` resolution the engine uses
|
|
122
|
+
this to decide whether to clear the ``_active_intents`` /
|
|
123
|
+
``_order_mapping`` slot tied to a now-live exchange order (yes for
|
|
124
|
+
new dispatches — no broker-side order ever materialised) or to
|
|
125
|
+
keep the original mapping and only drop the parked envelope
|
|
126
|
+
(modify case — the original order is still live and the next
|
|
127
|
+
:meth:`OrderSyncEngine._diff_and_dispatch` re-emits a modify rather
|
|
128
|
+
than a fresh order). Defaults to ``'new'`` for pre-v3 rows and any
|
|
129
|
+
code path that did not specify the kind explicitly.
|
|
130
|
+
|
|
131
|
+
``parked_ts_ms`` is the row's park timestamp (epoch ms). For
|
|
132
|
+
``dispatch_kind='cancel_tentative'`` rows it carries the original
|
|
133
|
+
cancel-tentative ``since_ts_ms`` anchor so a restart re-arms the
|
|
134
|
+
stale-grace deadline without slippage (see
|
|
135
|
+
:meth:`OrderSyncEngine._absorb_journal_retry_rows`).
|
|
136
|
+
"""
|
|
137
|
+
key: str
|
|
138
|
+
coid: str
|
|
139
|
+
resolution: str | None = None
|
|
140
|
+
dispatch_kind: str = 'new'
|
|
141
|
+
order_ids: list[str] = field(default_factory=list)
|
|
142
|
+
parked_ts_ms: int = 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class OrderRow:
|
|
147
|
+
"""One row of the ``orders`` table, exposed to the caller.
|
|
148
|
+
|
|
149
|
+
``extras`` is already a parsed dict (decoded from JSON), not the raw
|
|
150
|
+
string — so plugins can access broker-specific fields natively.
|
|
151
|
+
"""
|
|
152
|
+
client_order_id: str
|
|
153
|
+
plugin_name: str
|
|
154
|
+
intent_key: str | None
|
|
155
|
+
exchange_order_id: str | None
|
|
156
|
+
symbol: str
|
|
157
|
+
side: str
|
|
158
|
+
qty: float
|
|
159
|
+
filled_qty: float
|
|
160
|
+
state: str
|
|
161
|
+
from_entry: str | None
|
|
162
|
+
pine_entry_id: str | None
|
|
163
|
+
sl_level: float | None
|
|
164
|
+
tp_level: float | None
|
|
165
|
+
trailing_stop: bool
|
|
166
|
+
trailing_distance: float | None
|
|
167
|
+
created_ts_ms: int
|
|
168
|
+
updated_ts_ms: int
|
|
169
|
+
closed_ts_ms: int | None
|
|
170
|
+
extras: dict = field(default_factory=dict)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class SpotExecutionRow:
|
|
175
|
+
"""One row of the ``spot_executions`` ledger.
|
|
176
|
+
|
|
177
|
+
Numeric fields stay canonical decimal *strings* at this layer — the
|
|
178
|
+
:mod:`~pynecore.core.broker.spot_inventory` module owns the
|
|
179
|
+
``decimal.Decimal`` parse/serialize round trip; the storage layer
|
|
180
|
+
never does float arithmetic on them.
|
|
181
|
+
"""
|
|
182
|
+
fill_id: str
|
|
183
|
+
exchange_order_id: str | None
|
|
184
|
+
client_order_id: str | None
|
|
185
|
+
side: str # "buy" | "sell"
|
|
186
|
+
base_delta: str
|
|
187
|
+
quote_delta: str
|
|
188
|
+
price: str
|
|
189
|
+
fee_amount: str
|
|
190
|
+
fee_currency: str
|
|
191
|
+
ts_ms: int
|
|
192
|
+
delivered: bool
|
|
193
|
+
venue_seq: int | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class SpotEpochRow:
|
|
198
|
+
"""One row of the ``spot_inventory_epoch`` table."""
|
|
199
|
+
plugin_name: str
|
|
200
|
+
account_id: str
|
|
201
|
+
base_asset: str
|
|
202
|
+
product_id: str
|
|
203
|
+
epoch_seq: int
|
|
204
|
+
foreign_baseline: str
|
|
205
|
+
cursor_scope: str | None
|
|
206
|
+
exec_cursor: str | None
|
|
207
|
+
state: str # "active" | "quarantined" | "closed"
|
|
208
|
+
created_ts_ms: int
|
|
209
|
+
pending_conflict_ts_ms: int | None = None
|
|
210
|
+
pending_conflict: dict | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# === Schema migrations =====================================================
|
|
214
|
+
|
|
215
|
+
# This list is **append-only** — old tuples are never modified because
|
|
216
|
+
# they already represent state applied in production DBs. A new column
|
|
217
|
+
# or table arrives as a new tuple.
|
|
218
|
+
_MIGRATIONS: list[tuple[int, str, str]] = [
|
|
219
|
+
(1, "initial schema", """
|
|
220
|
+
CREATE TABLE _migrations (
|
|
221
|
+
version INTEGER PRIMARY KEY,
|
|
222
|
+
applied_ts_ms INTEGER NOT NULL,
|
|
223
|
+
description TEXT NOT NULL
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
CREATE TABLE runs (
|
|
227
|
+
run_instance_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
228
|
+
run_id TEXT NOT NULL,
|
|
229
|
+
run_tag TEXT NOT NULL,
|
|
230
|
+
strategy_id TEXT NOT NULL,
|
|
231
|
+
script_path TEXT NOT NULL,
|
|
232
|
+
symbol TEXT NOT NULL,
|
|
233
|
+
timeframe TEXT NOT NULL,
|
|
234
|
+
account_id TEXT NOT NULL,
|
|
235
|
+
run_label TEXT,
|
|
236
|
+
plugin_name TEXT NOT NULL,
|
|
237
|
+
started_ts_ms INTEGER NOT NULL,
|
|
238
|
+
last_heartbeat_ts_ms INTEGER NOT NULL,
|
|
239
|
+
ended_ts_ms INTEGER
|
|
240
|
+
);
|
|
241
|
+
CREATE INDEX idx_runs_run_id ON runs(run_id);
|
|
242
|
+
CREATE INDEX idx_runs_active ON runs(run_id)
|
|
243
|
+
WHERE ended_ts_ms IS NULL;
|
|
244
|
+
CREATE INDEX idx_runs_heartbeat ON runs(last_heartbeat_ts_ms)
|
|
245
|
+
WHERE ended_ts_ms IS NULL;
|
|
246
|
+
|
|
247
|
+
-- The 300000 ms (5 minute) threshold is HARD-CODED because SQLite
|
|
248
|
+
-- does not support parameterised VIEWs. If STALE_THRESHOLD_MS
|
|
249
|
+
-- changes, a new migration must DROP VIEW + CREATE VIEW.
|
|
250
|
+
CREATE VIEW live_runs AS
|
|
251
|
+
SELECT *
|
|
252
|
+
FROM runs
|
|
253
|
+
WHERE ended_ts_ms IS NULL
|
|
254
|
+
AND last_heartbeat_ts_ms > (
|
|
255
|
+
CAST(strftime('%s', 'now') AS INTEGER) * 1000 - 300000
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
-- Envelopes and pending_verifications are scoped to the LOGICAL
|
|
259
|
+
-- run_id, not run_instance_id — they are the broker-side
|
|
260
|
+
-- idempotency anchors that every restart (new instance)
|
|
261
|
+
-- inherits, because the same bot starts again with the same
|
|
262
|
+
-- intents. Orders/order_refs/events, by contrast, are
|
|
263
|
+
-- instance-scoped because they belong to the historical runs.
|
|
264
|
+
CREATE TABLE envelopes (
|
|
265
|
+
run_id TEXT NOT NULL,
|
|
266
|
+
intent_key TEXT NOT NULL,
|
|
267
|
+
bar_ts_ms INTEGER NOT NULL,
|
|
268
|
+
retry_seq INTEGER NOT NULL,
|
|
269
|
+
updated_ts_ms INTEGER NOT NULL,
|
|
270
|
+
PRIMARY KEY (run_id, intent_key)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
CREATE TABLE pending_verifications (
|
|
274
|
+
run_id TEXT NOT NULL,
|
|
275
|
+
client_order_id TEXT NOT NULL,
|
|
276
|
+
intent_key TEXT NOT NULL,
|
|
277
|
+
parked_ts_ms INTEGER NOT NULL,
|
|
278
|
+
PRIMARY KEY (run_id, client_order_id)
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
CREATE TABLE orders (
|
|
282
|
+
run_instance_id INTEGER NOT NULL,
|
|
283
|
+
client_order_id TEXT NOT NULL,
|
|
284
|
+
plugin_name TEXT NOT NULL,
|
|
285
|
+
intent_key TEXT,
|
|
286
|
+
exchange_order_id TEXT,
|
|
287
|
+
symbol TEXT NOT NULL,
|
|
288
|
+
side TEXT NOT NULL,
|
|
289
|
+
qty REAL NOT NULL,
|
|
290
|
+
filled_qty REAL DEFAULT 0.0,
|
|
291
|
+
state TEXT NOT NULL,
|
|
292
|
+
from_entry TEXT,
|
|
293
|
+
pine_entry_id TEXT,
|
|
294
|
+
sl_level REAL,
|
|
295
|
+
tp_level REAL,
|
|
296
|
+
trailing_stop INTEGER DEFAULT 0,
|
|
297
|
+
trailing_distance REAL,
|
|
298
|
+
created_ts_ms INTEGER NOT NULL,
|
|
299
|
+
updated_ts_ms INTEGER NOT NULL,
|
|
300
|
+
closed_ts_ms INTEGER,
|
|
301
|
+
extras TEXT,
|
|
302
|
+
PRIMARY KEY (run_instance_id, client_order_id),
|
|
303
|
+
FOREIGN KEY (run_instance_id) REFERENCES runs(run_instance_id)
|
|
304
|
+
);
|
|
305
|
+
CREATE INDEX idx_orders_live ON orders(run_instance_id, symbol)
|
|
306
|
+
WHERE closed_ts_ms IS NULL;
|
|
307
|
+
CREATE INDEX idx_orders_entry ON orders(run_instance_id, from_entry)
|
|
308
|
+
WHERE closed_ts_ms IS NULL;
|
|
309
|
+
|
|
310
|
+
CREATE TABLE order_refs (
|
|
311
|
+
run_instance_id INTEGER NOT NULL,
|
|
312
|
+
ref_type TEXT NOT NULL,
|
|
313
|
+
ref_value TEXT NOT NULL,
|
|
314
|
+
client_order_id TEXT NOT NULL,
|
|
315
|
+
created_ts_ms INTEGER NOT NULL,
|
|
316
|
+
PRIMARY KEY (run_instance_id, ref_type, ref_value),
|
|
317
|
+
FOREIGN KEY (run_instance_id) REFERENCES runs(run_instance_id)
|
|
318
|
+
);
|
|
319
|
+
CREATE INDEX idx_order_refs_coid ON order_refs(run_instance_id, client_order_id);
|
|
320
|
+
|
|
321
|
+
CREATE TABLE events (
|
|
322
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
323
|
+
run_instance_id INTEGER NOT NULL,
|
|
324
|
+
ts_ms INTEGER NOT NULL,
|
|
325
|
+
plugin_name TEXT NOT NULL,
|
|
326
|
+
kind TEXT NOT NULL,
|
|
327
|
+
client_order_id TEXT,
|
|
328
|
+
exchange_order_id TEXT,
|
|
329
|
+
intent_key TEXT,
|
|
330
|
+
payload TEXT,
|
|
331
|
+
FOREIGN KEY (run_instance_id) REFERENCES runs(run_instance_id)
|
|
332
|
+
);
|
|
333
|
+
CREATE INDEX idx_events_run_ts ON events(run_instance_id, ts_ms);
|
|
334
|
+
CREATE INDEX idx_events_coid ON events(run_instance_id, client_order_id);
|
|
335
|
+
CREATE INDEX idx_events_kind_ts ON events(run_instance_id, kind, ts_ms);
|
|
336
|
+
"""),
|
|
337
|
+
(2, "pending_verifications resolution column", """
|
|
338
|
+
-- Plugin-driven resolution channel for parked dispatches whose
|
|
339
|
+
-- exchange-side disposition the engine cannot observe via
|
|
340
|
+
-- get_open_orders (e.g. position-attached brackets on Capital.com).
|
|
341
|
+
-- The plugin's snapshot recovery writes 'attached' or 'rejected'
|
|
342
|
+
-- here; OrderSyncEngine._verify_pending_dispatches consumes it on
|
|
343
|
+
-- the next sync. NULL = still parked, default behaviour.
|
|
344
|
+
ALTER TABLE pending_verifications
|
|
345
|
+
ADD COLUMN resolution TEXT;
|
|
346
|
+
"""),
|
|
347
|
+
(3, "pending_verifications dispatch_kind column", """
|
|
348
|
+
-- Distinguishes a parked 'new' dispatch (execute_entry/exit/close)
|
|
349
|
+
-- from a parked 'modify' dispatch (modify_entry/modify_exit). The
|
|
350
|
+
-- 'rejected' path in OrderSyncEngine._consume_plugin_resolutions
|
|
351
|
+
-- must NOT clear _active_intents/_order_mapping when the original
|
|
352
|
+
-- exchange order is still live and only the amend failed —
|
|
353
|
+
-- otherwise the next _diff_and_dispatch treats the Pine intent as
|
|
354
|
+
-- brand new and re-dispatches via execute_*, creating a duplicate
|
|
355
|
+
-- order alongside the still-live original. Default 'new' covers
|
|
356
|
+
-- pre-migration rows and the unspecified-kind path.
|
|
357
|
+
ALTER TABLE pending_verifications
|
|
358
|
+
ADD COLUMN dispatch_kind TEXT NOT NULL DEFAULT 'new';
|
|
359
|
+
"""),
|
|
360
|
+
(4, "pending_verifications order_ids column", """
|
|
361
|
+
-- Stores the ``_order_mapping[key]`` snapshot at park time as a
|
|
362
|
+
-- JSON array so a post-restart modify-rejected resolution can
|
|
363
|
+
-- recover the original exchange order IDs and prevent a duplicate
|
|
364
|
+
-- ``execute_*`` dispatch.
|
|
365
|
+
ALTER TABLE pending_verifications
|
|
366
|
+
ADD COLUMN order_ids TEXT NOT NULL DEFAULT '[]';
|
|
367
|
+
"""),
|
|
368
|
+
(5, "spot inventory tables", """
|
|
369
|
+
-- Append-only per-fill execution ledger for spot venues. The
|
|
370
|
+
-- ``orders`` table keeps only a cumulative filled_qty and the
|
|
371
|
+
-- ``events`` table is retention-purged; neither can reconstruct
|
|
372
|
+
-- a spot position that has been open longer than the retention
|
|
373
|
+
-- window, so spot inventory gets its own ledger that
|
|
374
|
+
-- ``cleanup_old_data`` never touches. Numeric columns are
|
|
375
|
+
-- canonical decimal STRINGS (see
|
|
376
|
+
-- ``pynecore.core.broker.spot_inventory``) — float accumulation
|
|
377
|
+
-- over crypto atoms is not acceptable.
|
|
378
|
+
--
|
|
379
|
+
-- The PK carries the venue's fill-id uniqueness dimension
|
|
380
|
+
-- (account + product); ``run_id`` is deliberately NOT part of it
|
|
381
|
+
-- so the same venue execution can never be booked under two
|
|
382
|
+
-- logical runs. ``delivered`` is the engine-outbox marker: 0 =
|
|
383
|
+
-- recorded but not yet handed to the sync engine (startup
|
|
384
|
+
-- adoption folds such rows into the synthesized position).
|
|
385
|
+
-- ``venue_seq`` is the venue's monotonic execution-sequence key
|
|
386
|
+
-- (NULL when the venue exposes none); the fold orders by
|
|
387
|
+
-- (ts_ms, venue_seq, fill_id) so a buy and a sell sharing one
|
|
388
|
+
-- millisecond cannot replay in the wrong order and fabricate a
|
|
389
|
+
-- false oversell.
|
|
390
|
+
CREATE TABLE spot_executions (
|
|
391
|
+
run_id TEXT NOT NULL,
|
|
392
|
+
account_id TEXT NOT NULL,
|
|
393
|
+
product_id TEXT NOT NULL,
|
|
394
|
+
fill_id TEXT NOT NULL,
|
|
395
|
+
exchange_order_id TEXT,
|
|
396
|
+
client_order_id TEXT,
|
|
397
|
+
side TEXT NOT NULL,
|
|
398
|
+
base_delta TEXT NOT NULL,
|
|
399
|
+
quote_delta TEXT NOT NULL,
|
|
400
|
+
price TEXT NOT NULL,
|
|
401
|
+
fee_amount TEXT NOT NULL,
|
|
402
|
+
fee_currency TEXT NOT NULL,
|
|
403
|
+
ts_ms INTEGER NOT NULL,
|
|
404
|
+
venue_seq INTEGER,
|
|
405
|
+
delivered INTEGER NOT NULL DEFAULT 0,
|
|
406
|
+
PRIMARY KEY (account_id, product_id, fill_id)
|
|
407
|
+
);
|
|
408
|
+
CREATE INDEX idx_spot_exec_fold
|
|
409
|
+
ON spot_executions(run_id, account_id, product_id,
|
|
410
|
+
ts_ms, venue_seq, fill_id);
|
|
411
|
+
|
|
412
|
+
-- Inventory epoch: the reconciliation baseline generation. The
|
|
413
|
+
-- balance invariant is
|
|
414
|
+
-- expected_total = foreign_baseline + bot_inventory(ledger)
|
|
415
|
+
-- where foreign_baseline was frozen at epoch creation as
|
|
416
|
+
-- (current_total - reconstructed bot inventory). ``exec_cursor``
|
|
417
|
+
-- is the plugin's durable execution-history cursor; it may only
|
|
418
|
+
-- advance in the transaction that recorded every execution
|
|
419
|
+
-- before it. ``pending_conflict_*`` persist the runtime
|
|
420
|
+
-- settlement-grace state so a crash loop cannot keep resetting
|
|
421
|
+
-- the grace window and mask a real drift.
|
|
422
|
+
CREATE TABLE spot_inventory_epoch (
|
|
423
|
+
run_id TEXT NOT NULL,
|
|
424
|
+
plugin_name TEXT NOT NULL,
|
|
425
|
+
account_id TEXT NOT NULL,
|
|
426
|
+
base_asset TEXT NOT NULL,
|
|
427
|
+
product_id TEXT NOT NULL,
|
|
428
|
+
epoch_seq INTEGER NOT NULL,
|
|
429
|
+
foreign_baseline TEXT NOT NULL,
|
|
430
|
+
cursor_scope TEXT,
|
|
431
|
+
exec_cursor TEXT,
|
|
432
|
+
state TEXT NOT NULL,
|
|
433
|
+
created_ts_ms INTEGER NOT NULL,
|
|
434
|
+
pending_conflict_ts_ms INTEGER,
|
|
435
|
+
pending_conflict TEXT,
|
|
436
|
+
PRIMARY KEY (run_id, product_id, epoch_seq)
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
-- Atomic ownership claim: one active logical run per
|
|
440
|
+
-- (plugin, account, base asset). The UNIQUE constraint turns
|
|
441
|
+
-- check-then-act races into a hard conflict; an expired
|
|
442
|
+
-- heartbeat is taken over. ``run_instance_id`` is the PHYSICAL
|
|
443
|
+
-- claimant (run_id is reused across restarts by design) — a
|
|
444
|
+
-- resumed zombie whose run_id still matches but whose instance
|
|
445
|
+
-- does not cannot steal the lease back, and its heartbeat
|
|
446
|
+
-- becomes a detected no-op. ``quote_asset`` lets the claim
|
|
447
|
+
-- reject a base-vs-quote overlap (another live run trading the
|
|
448
|
+
-- shared asset as cash) before it silently mutates this run's
|
|
449
|
+
-- balance invariant. Scope caveat: the constraint is local to
|
|
450
|
+
-- this SQLite file — a bot started from another workdir/machine
|
|
451
|
+
-- is only DETECTED by the balance invariant, not prevented here.
|
|
452
|
+
-- No FK on ``run_instance_id``: the lease outlives its run row on
|
|
453
|
+
-- purpose (retention may purge an ended run while a not-yet-taken
|
|
454
|
+
-- -over lease still points at it), and the claim path resolves a
|
|
455
|
+
-- missing prior run as "not live" (takeover permitted) rather
|
|
456
|
+
-- than relying on referential integrity.
|
|
457
|
+
CREATE TABLE spot_asset_owner (
|
|
458
|
+
plugin_name TEXT NOT NULL,
|
|
459
|
+
account_id TEXT NOT NULL,
|
|
460
|
+
base_asset TEXT NOT NULL,
|
|
461
|
+
quote_asset TEXT NOT NULL,
|
|
462
|
+
run_id TEXT NOT NULL,
|
|
463
|
+
run_instance_id INTEGER NOT NULL,
|
|
464
|
+
claimed_ts_ms INTEGER NOT NULL,
|
|
465
|
+
heartbeat_ts_ms INTEGER NOT NULL,
|
|
466
|
+
UNIQUE (plugin_name, account_id, base_asset)
|
|
467
|
+
);
|
|
468
|
+
"""),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _apply_migrations(conn: sqlite3.Connection) -> None:
|
|
473
|
+
"""Migrate the schema from ``PRAGMA user_version`` up to the latest.
|
|
474
|
+
|
|
475
|
+
:param conn: An open ``sqlite3.Connection``. The function opens
|
|
476
|
+
transaction blocks on the connection; the caller must be in a
|
|
477
|
+
transaction-free state (``conn.isolation_level`` at its default
|
|
478
|
+
``""``).
|
|
479
|
+
"""
|
|
480
|
+
current = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
481
|
+
for version, description, sql in _MIGRATIONS:
|
|
482
|
+
if version <= current:
|
|
483
|
+
continue
|
|
484
|
+
# ``executescript`` COMMITs any pending transaction on entry and
|
|
485
|
+
# adds NO implicit transaction control of its own — a plain
|
|
486
|
+
# ``with conn`` around it would be committed away before the DDL
|
|
487
|
+
# runs, leaving each statement in its own autocommit span. A
|
|
488
|
+
# crash between two CREATEs would then leave a half-built schema
|
|
489
|
+
# while ``user_version`` stays put, and the retry would fail
|
|
490
|
+
# permanently on the already-existing table. So the atomicity is
|
|
491
|
+
# embedded IN the script: BEGIN IMMEDIATE ... COMMIT wraps the
|
|
492
|
+
# DDL, the ``_migrations`` bookkeeping and the ``user_version``
|
|
493
|
+
# bump (all three transactional, verified — a rolled-back script
|
|
494
|
+
# leaves ``user_version`` untouched). The ``_migrations`` table
|
|
495
|
+
# is created inside migration 1's own script, so the INSERT is
|
|
496
|
+
# safe on the very first invocation too.
|
|
497
|
+
desc_literal = description.replace("'", "''")
|
|
498
|
+
script = (
|
|
499
|
+
"BEGIN IMMEDIATE;\n"
|
|
500
|
+
f"{sql}\n"
|
|
501
|
+
"INSERT INTO _migrations (version, applied_ts_ms, description) "
|
|
502
|
+
f"VALUES ({version}, {_now_ms()}, '{desc_literal}');\n"
|
|
503
|
+
f"PRAGMA user_version = {version};\n"
|
|
504
|
+
"COMMIT;"
|
|
505
|
+
)
|
|
506
|
+
try:
|
|
507
|
+
conn.executescript(script)
|
|
508
|
+
except Exception:
|
|
509
|
+
# The script aborted with its transaction still open; roll
|
|
510
|
+
# the partial schema (and the version bump) back so a retry
|
|
511
|
+
# starts from a clean, consistent state.
|
|
512
|
+
if conn.in_transaction:
|
|
513
|
+
conn.rollback()
|
|
514
|
+
raise
|
|
515
|
+
_log.info("broker storage migrated to version %d (%s)", version, description)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
_LIVE_RUNS_VIEW_SQL = f"""\
|
|
519
|
+
CREATE VIEW live_runs AS
|
|
520
|
+
SELECT *
|
|
521
|
+
FROM runs
|
|
522
|
+
WHERE ended_ts_ms IS NULL
|
|
523
|
+
AND last_heartbeat_ts_ms > (
|
|
524
|
+
CAST(strftime('%s', 'now') AS INTEGER) * 1000 - {STALE_THRESHOLD_MS}
|
|
525
|
+
)"""
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _heal_live_runs_view(conn: sqlite3.Connection) -> None:
|
|
529
|
+
"""Recreate the ``live_runs`` VIEW when its stored staleness threshold
|
|
530
|
+
drifts from :data:`STALE_THRESHOLD_MS`.
|
|
531
|
+
|
|
532
|
+
The migration that created the VIEW baked the threshold in as a
|
|
533
|
+
literal (SQLite cannot parameterise a VIEW), and the migration list
|
|
534
|
+
is append-only history — so a later change to
|
|
535
|
+
:data:`STALE_THRESHOLD_MS` would silently leave existing DBs
|
|
536
|
+
filtering on the old value. Healing outside the migration chain keeps
|
|
537
|
+
every DB consistent with the running code without a schema-version
|
|
538
|
+
bump. The membership check is the no-op fast path: matching DBs are
|
|
539
|
+
not write-locked on open.
|
|
540
|
+
"""
|
|
541
|
+
row = conn.execute(
|
|
542
|
+
"SELECT sql FROM sqlite_master WHERE type = 'view' AND name = 'live_runs'"
|
|
543
|
+
).fetchone()
|
|
544
|
+
if row is not None and f"- {STALE_THRESHOLD_MS}" in row[0]:
|
|
545
|
+
return
|
|
546
|
+
with conn:
|
|
547
|
+
conn.execute("DROP VIEW IF EXISTS live_runs")
|
|
548
|
+
conn.execute(_LIVE_RUNS_VIEW_SQL)
|
|
549
|
+
_log.info("broker storage live_runs VIEW recreated with stale threshold %d ms",
|
|
550
|
+
STALE_THRESHOLD_MS)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# === BrokerStore ===========================================================
|
|
554
|
+
|
|
555
|
+
class TransactionRollbackError(Exception):
|
|
556
|
+
"""Raised at the outermost :meth:`BrokerStore.transaction` boundary when
|
|
557
|
+
a nested level exited exceptionally and the exception was swallowed
|
|
558
|
+
*inside* the span.
|
|
559
|
+
|
|
560
|
+
The whole span was rolled back — none of its writes committed. A caller
|
|
561
|
+
that swallowed an inner failure and kept going therefore does NOT get a
|
|
562
|
+
silently-successful ``with`` block; this error signals the discarded
|
|
563
|
+
span so it cannot mistake it for a commit. Chained (``from``) the first
|
|
564
|
+
nested exception.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class _TransactionAborted(Exception):
|
|
569
|
+
"""Internal sentinel forcing the outermost ``transaction()`` span to
|
|
570
|
+
roll back.
|
|
571
|
+
|
|
572
|
+
A nested ``transaction()`` level that exits with an exception marks the
|
|
573
|
+
whole span rollback-only; if the exception is then swallowed *inside*
|
|
574
|
+
the outer block, the outermost ``with conn:`` would otherwise see no
|
|
575
|
+
exception and COMMIT the partial work. Raising this sentinel just before
|
|
576
|
+
the outer block would commit makes ``sqlite3`` roll back instead; the
|
|
577
|
+
outermost level converts it into a :class:`TransactionRollbackError`.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class BrokerStore:
|
|
582
|
+
"""Unified SQLite broker-state store for one workdir.
|
|
583
|
+
|
|
584
|
+
Construction opens the DB, applies migrations and sets up the
|
|
585
|
+
WAL + crash-safe PRAGMAs. Every ``open_run()`` returns a fresh
|
|
586
|
+
:class:`RunContext` — every actual data movement goes through it.
|
|
587
|
+
|
|
588
|
+
:param path: Absolute path of the SQLite file. The parent directory
|
|
589
|
+
is created automatically.
|
|
590
|
+
:param plugin_name: The BrokerPlugin's ``plugin_name`` attribute
|
|
591
|
+
(e.g. ``"Capital.com"``). Every ``events`` / ``orders`` row
|
|
592
|
+
carries this value so a multi-plugin workdir can be filtered.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
def __init__(self, path: Path | str, *, plugin_name: str) -> None:
|
|
596
|
+
self._path = Path(path)
|
|
597
|
+
self._plugin_name = plugin_name
|
|
598
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
599
|
+
# The connection is shared by the run thread and the broker
|
|
600
|
+
# event-loop thread (``check_same_thread=False``). ``sqlite3``'s
|
|
601
|
+
# implicit-transaction state is connection-global, so every
|
|
602
|
+
# BEGIN…COMMIT span must be serialized through this re-entrant
|
|
603
|
+
# lock — see :meth:`transaction`.
|
|
604
|
+
self._lock = threading.RLock()
|
|
605
|
+
# Same-thread nesting depth of :meth:`transaction` — only the
|
|
606
|
+
# outermost level opens the real BEGIN…COMMIT span. Guarded by
|
|
607
|
+
# ``_lock`` (re-entrant), so cross-thread spans stay serialized.
|
|
608
|
+
self._txn_depth = 0
|
|
609
|
+
# Set when any nested :meth:`transaction` level exits with an
|
|
610
|
+
# exception; forces the outermost span to roll back even if the
|
|
611
|
+
# exception was swallowed inside the outer block. ``_txn_rollback_cause``
|
|
612
|
+
# holds the first such exception, chained into the surfaced
|
|
613
|
+
# :class:`TransactionRollbackError`.
|
|
614
|
+
self._txn_rollback_only = False
|
|
615
|
+
self._txn_rollback_cause: BaseException | None = None
|
|
616
|
+
# Gate for :meth:`maybe_cleanup_old_data` — 0 means the first
|
|
617
|
+
# caller (``open_run``) purges immediately.
|
|
618
|
+
self._last_purge_ms = 0
|
|
619
|
+
# ``isolation_level=""`` = default; we open explicit transactions
|
|
620
|
+
# via :meth:`transaction` (which wraps ``with conn:``). The
|
|
621
|
+
# sqlite3 module's autocommit mode is not what it looks like at
|
|
622
|
+
# first glance — the default behaviour is to start an implicit
|
|
623
|
+
# BEGIN before DML and close it on the next commit. That fits our
|
|
624
|
+
# needs.
|
|
625
|
+
# noinspection PyTypeChecker
|
|
626
|
+
self._conn: sqlite3.Connection = sqlite3.connect(
|
|
627
|
+
str(self._path),
|
|
628
|
+
isolation_level="",
|
|
629
|
+
check_same_thread=False,
|
|
630
|
+
timeout=5.0,
|
|
631
|
+
)
|
|
632
|
+
# ``sqlite3.Row`` returns columns accessible by name — the
|
|
633
|
+
# query helpers stay position-insensitive, so adding a column
|
|
634
|
+
# later is easy.
|
|
635
|
+
self._conn.row_factory = sqlite3.Row
|
|
636
|
+
self._configure_pragmas()
|
|
637
|
+
_apply_migrations(self._conn)
|
|
638
|
+
_heal_live_runs_view(self._conn)
|
|
639
|
+
|
|
640
|
+
def _configure_pragmas(self) -> None:
|
|
641
|
+
"""Configure WAL + crash-safety + FK + busy-timeout."""
|
|
642
|
+
# WAL: concurrent read + single writer; crash-safe under power loss.
|
|
643
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
644
|
+
# NORMAL: crash-safe with WAL, faster than FULL.
|
|
645
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
646
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
647
|
+
# 5 s wait on lock collision — rare with a single writer, but
|
|
648
|
+
# parallel debug-CLI invocations can trigger it.
|
|
649
|
+
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
650
|
+
|
|
651
|
+
@contextlib.contextmanager
|
|
652
|
+
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
653
|
+
"""Serialized write transaction over the shared connection.
|
|
654
|
+
|
|
655
|
+
The connection is shared by the run thread (per-bar
|
|
656
|
+
:meth:`OrderSyncEngine.sync` → ``record_envelope`` etc.) and the
|
|
657
|
+
broker event-loop thread (``watch_orders`` PUSH events →
|
|
658
|
+
``log_event`` / ``upsert_order`` / ``set_filled``). ``sqlite3``'s
|
|
659
|
+
implicit-transaction state is connection-global: two overlapping
|
|
660
|
+
``with conn:`` blocks let one thread's COMMIT close the other's
|
|
661
|
+
transaction, so the late COMMIT raises ``OperationalError: cannot
|
|
662
|
+
commit - no transaction is active``. The re-entrant lock makes
|
|
663
|
+
each BEGIN…COMMIT span mutually exclusive across the two threads.
|
|
664
|
+
|
|
665
|
+
Standalone reads must also take the lock — see :meth:`read_lock`.
|
|
666
|
+
They open no transaction of their own, but transaction visibility
|
|
667
|
+
is connection-global: a read issued while the writer thread is
|
|
668
|
+
mid-transaction sees that writer's uncommitted rows, and a later
|
|
669
|
+
writer rollback leaves the reader having acted on phantom data.
|
|
670
|
+
|
|
671
|
+
**Nestable on the same thread.** A ``transaction()`` block opened
|
|
672
|
+
inside another one joins the outer span instead of opening (and
|
|
673
|
+
prematurely committing) its own — sqlite3's connection context
|
|
674
|
+
manager is not nesting-safe, so only the outermost level runs the
|
|
675
|
+
real ``with conn:``. Composite writers (e.g. the disappearance
|
|
676
|
+
tracker's confirm-outcome apply) rely on this to wrap several
|
|
677
|
+
existing single-transaction helpers into one atomic span.
|
|
678
|
+
|
|
679
|
+
**All-or-nothing under swallowed inner exceptions.** An exception
|
|
680
|
+
anywhere inside the span rolls back the whole outer span — even
|
|
681
|
+
when a caller catches it *inside* the outer block. A nested level
|
|
682
|
+
that exits exceptionally marks the span rollback-only; the
|
|
683
|
+
outermost level then rolls back instead of committing the partial
|
|
684
|
+
work and raises :class:`TransactionRollbackError` at its boundary
|
|
685
|
+
so the caller cannot mistake the discarded span for a commit.
|
|
686
|
+
Recovering from an inner failure and continuing to write in the
|
|
687
|
+
same span is therefore impossible by design — start a fresh
|
|
688
|
+
(non-nested) span for work that must survive.
|
|
689
|
+
"""
|
|
690
|
+
with self._lock:
|
|
691
|
+
if self._txn_depth > 0:
|
|
692
|
+
self._txn_depth += 1
|
|
693
|
+
try:
|
|
694
|
+
yield self._conn
|
|
695
|
+
except BaseException as exc:
|
|
696
|
+
self._txn_rollback_only = True
|
|
697
|
+
if self._txn_rollback_cause is None:
|
|
698
|
+
self._txn_rollback_cause = exc
|
|
699
|
+
raise
|
|
700
|
+
finally:
|
|
701
|
+
self._txn_depth -= 1
|
|
702
|
+
else:
|
|
703
|
+
self._txn_depth = 1
|
|
704
|
+
self._txn_rollback_only = False
|
|
705
|
+
self._txn_rollback_cause = None
|
|
706
|
+
try:
|
|
707
|
+
with self._conn:
|
|
708
|
+
yield self._conn
|
|
709
|
+
if self._txn_rollback_only:
|
|
710
|
+
raise _TransactionAborted
|
|
711
|
+
except _TransactionAborted:
|
|
712
|
+
cause = self._txn_rollback_cause
|
|
713
|
+
raise TransactionRollbackError(
|
|
714
|
+
"nested transaction level failed and was swallowed "
|
|
715
|
+
"inside the span; the whole span was rolled back"
|
|
716
|
+
) from cause
|
|
717
|
+
finally:
|
|
718
|
+
self._txn_depth = 0
|
|
719
|
+
self._txn_rollback_only = False
|
|
720
|
+
self._txn_rollback_cause = None
|
|
721
|
+
|
|
722
|
+
@contextlib.contextmanager
|
|
723
|
+
def immediate_transaction(self) -> Iterator[sqlite3.Connection]:
|
|
724
|
+
"""A write transaction that takes the DB write lock UP FRONT.
|
|
725
|
+
|
|
726
|
+
:meth:`transaction` opens a DEFERRED span (sqlite3's ``with
|
|
727
|
+
conn:``), so a check-then-insert reads under a shared snapshot and
|
|
728
|
+
two SEPARATE connections to the same file can both pass the
|
|
729
|
+
pre-write check before either writes — a real hazard for the
|
|
730
|
+
base-vs-quote exclusion in :meth:`RunContext.claim_spot_asset`,
|
|
731
|
+
which the per-store re-entrant lock cannot prevent because it only
|
|
732
|
+
serializes users of ONE connection. ``BEGIN IMMEDIATE`` acquires
|
|
733
|
+
the database write lock before the first read, so a concurrent
|
|
734
|
+
claimant on another connection blocks (up to ``busy_timeout``) and
|
|
735
|
+
then reads the first claim's committed rows.
|
|
736
|
+
|
|
737
|
+
Top-level only — it must not nest inside an open
|
|
738
|
+
:meth:`transaction` span (a raised :class:`RuntimeError` guards
|
|
739
|
+
that).
|
|
740
|
+
"""
|
|
741
|
+
with self._lock:
|
|
742
|
+
if self._txn_depth > 0:
|
|
743
|
+
raise RuntimeError(
|
|
744
|
+
"immediate_transaction() cannot nest inside an open "
|
|
745
|
+
"transaction span"
|
|
746
|
+
)
|
|
747
|
+
# At depth 0 the store holds no span of its own, but sqlite3's
|
|
748
|
+
# legacy isolation may have a dangling implicit transaction
|
|
749
|
+
# from a bare ``execute`` (the deferred ``with conn:`` path
|
|
750
|
+
# would have committed it on exit) — settle it before the
|
|
751
|
+
# explicit BEGIN, which cannot nest.
|
|
752
|
+
if self._conn.in_transaction:
|
|
753
|
+
self._conn.commit()
|
|
754
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
755
|
+
self._txn_depth = 1
|
|
756
|
+
try:
|
|
757
|
+
yield self._conn
|
|
758
|
+
except BaseException:
|
|
759
|
+
self._conn.rollback()
|
|
760
|
+
raise
|
|
761
|
+
else:
|
|
762
|
+
self._conn.commit()
|
|
763
|
+
finally:
|
|
764
|
+
self._txn_depth = 0
|
|
765
|
+
|
|
766
|
+
@contextlib.contextmanager
|
|
767
|
+
def read_lock(self) -> Iterator[sqlite3.Connection]:
|
|
768
|
+
"""Serialize a standalone read against the concurrent writer.
|
|
769
|
+
|
|
770
|
+
Reads open no transaction, but they share the connection with the
|
|
771
|
+
writer thread, and SQLite's transaction visibility is
|
|
772
|
+
connection-global: a read issued mid-write sees the writer's
|
|
773
|
+
uncommitted rows, and a subsequent writer rollback leaves the
|
|
774
|
+
reader having acted on phantom data. Holding :attr:`_lock` for the
|
|
775
|
+
fetch closes that window. The lock is re-entrant, so a read nested
|
|
776
|
+
inside a :meth:`transaction` block on the same thread is safe.
|
|
777
|
+
|
|
778
|
+
Fetch eagerly inside the block (``fetchone`` / ``fetchall``) so the
|
|
779
|
+
lock is not held while the caller processes rows.
|
|
780
|
+
"""
|
|
781
|
+
with self._lock:
|
|
782
|
+
yield self._conn
|
|
783
|
+
|
|
784
|
+
# --- Lifecycle ---------------------------------------------------------
|
|
785
|
+
|
|
786
|
+
@property
|
|
787
|
+
def path(self) -> Path:
|
|
788
|
+
return self._path
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def plugin_name(self) -> str:
|
|
792
|
+
return self._plugin_name
|
|
793
|
+
|
|
794
|
+
def close(self) -> None:
|
|
795
|
+
"""Close the connection. Repeated calls are no-ops."""
|
|
796
|
+
if self._conn is None:
|
|
797
|
+
return
|
|
798
|
+
try:
|
|
799
|
+
self._conn.close()
|
|
800
|
+
finally:
|
|
801
|
+
self._conn = None # type: ignore[assignment]
|
|
802
|
+
|
|
803
|
+
def __enter__(self) -> 'BrokerStore':
|
|
804
|
+
return self
|
|
805
|
+
|
|
806
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
807
|
+
self.close()
|
|
808
|
+
|
|
809
|
+
# --- Run lifecycle -----------------------------------------------------
|
|
810
|
+
|
|
811
|
+
def open_run(
|
|
812
|
+
self,
|
|
813
|
+
identity: RunIdentity,
|
|
814
|
+
*,
|
|
815
|
+
script_source: str,
|
|
816
|
+
script_path: str | Path = "",
|
|
817
|
+
) -> 'RunContext':
|
|
818
|
+
"""Open a new run instance.
|
|
819
|
+
|
|
820
|
+
Four steps in a single transaction:
|
|
821
|
+
|
|
822
|
+
1. Stale-run cleanup: every row marked alive whose
|
|
823
|
+
``last_heartbeat_ts_ms`` has expired is closed by setting
|
|
824
|
+
``ended_ts_ms`` and gets a ``stale_run_cleaned`` event.
|
|
825
|
+
2. Live-collision check: if a live row with the same ``run_id``
|
|
826
|
+
still exists after cleanup, raise ``RuntimeError``.
|
|
827
|
+
3. INSERT a new ``runs`` row.
|
|
828
|
+
4. Order adoption: every live order
|
|
829
|
+
(``closed_ts_ms IS NULL``) and its ``order_refs`` rows
|
|
830
|
+
that still belong to a previous (now ended) instance of the
|
|
831
|
+
same logical ``run_id`` get re-pointed to the fresh
|
|
832
|
+
``run_instance_id`` and audited via an ``order_adopted``
|
|
833
|
+
event. Without this step, ``iter_live_orders`` /
|
|
834
|
+
:func:`~pynecore.core.broker.store_helpers.find_pending_dispatch`
|
|
835
|
+
would not see pending dispatches left behind by a crashed
|
|
836
|
+
instance, and :meth:`DispatchJournal.recover_pending` would
|
|
837
|
+
return an empty result after a real restart.
|
|
838
|
+
|
|
839
|
+
:param identity: Logical identity of the run
|
|
840
|
+
(strategy, symbol, ...).
|
|
841
|
+
:param script_source: Full source of the Pine script — fed into
|
|
842
|
+
``run_tag`` generation.
|
|
843
|
+
:param script_path: Path to the script file (audit metadata
|
|
844
|
+
only; empty string is allowed).
|
|
845
|
+
:raises RuntimeError: If a live run already exists with the
|
|
846
|
+
same ``run_id``.
|
|
847
|
+
:return: A freshly opened :class:`RunContext`.
|
|
848
|
+
"""
|
|
849
|
+
run_id = identity.run_id
|
|
850
|
+
run_tag = identity.make_run_tag(script_source)
|
|
851
|
+
now = _now_ms()
|
|
852
|
+
|
|
853
|
+
with self.transaction():
|
|
854
|
+
# (1) Stale cleanup — close every expired live row
|
|
855
|
+
self._cleanup_stale_runs_inside_tx(now=now)
|
|
856
|
+
|
|
857
|
+
# (2) Collision check AFTER cleanup
|
|
858
|
+
row = self._conn.execute(
|
|
859
|
+
"SELECT run_instance_id, last_heartbeat_ts_ms FROM runs "
|
|
860
|
+
"WHERE run_id = ? AND ended_ts_ms IS NULL",
|
|
861
|
+
(run_id,),
|
|
862
|
+
).fetchone()
|
|
863
|
+
if row is not None:
|
|
864
|
+
raise RuntimeError(
|
|
865
|
+
f"Active run_id already exists: {run_id!r} "
|
|
866
|
+
f"(run_instance_id={row['run_instance_id']}, "
|
|
867
|
+
f"last_heartbeat={row['last_heartbeat_ts_ms']}). "
|
|
868
|
+
f"Pass `--run-label`, or stop the previous instance."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# (3) INSERT a new instance
|
|
872
|
+
cur = self._conn.execute(
|
|
873
|
+
"INSERT INTO runs ("
|
|
874
|
+
" run_id, run_tag, strategy_id, script_path, symbol, timeframe,"
|
|
875
|
+
" account_id, run_label, plugin_name,"
|
|
876
|
+
" started_ts_ms, last_heartbeat_ts_ms"
|
|
877
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
878
|
+
(
|
|
879
|
+
run_id, run_tag,
|
|
880
|
+
identity.strategy_id, str(script_path),
|
|
881
|
+
identity.symbol, identity.timeframe,
|
|
882
|
+
identity.account_id, identity.label,
|
|
883
|
+
self._plugin_name,
|
|
884
|
+
now, now,
|
|
885
|
+
),
|
|
886
|
+
)
|
|
887
|
+
run_instance_id = cur.lastrowid
|
|
888
|
+
if run_instance_id is None:
|
|
889
|
+
# Theoretical case: AUTOINCREMENT always returns a
|
|
890
|
+
# lastrowid; handled only to keep the static analyzer
|
|
891
|
+
# happy.
|
|
892
|
+
raise RuntimeError("sqlite3 lastrowid is None after INSERT")
|
|
893
|
+
|
|
894
|
+
# (4) Adopt orphan live orders + refs left behind by previous
|
|
895
|
+
# instances of the same run_id (crash recovery).
|
|
896
|
+
self._adopt_orphan_rows_inside_tx(
|
|
897
|
+
now=now,
|
|
898
|
+
new_run_instance_id=run_instance_id,
|
|
899
|
+
run_id=run_id,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
# Retention purge piggybacks on startup; the other trigger is
|
|
903
|
+
# the daily gate in :meth:`RunContext.heartbeat`. Outside the
|
|
904
|
+
# main transaction — a purge failure must not block the run.
|
|
905
|
+
self.maybe_cleanup_old_data()
|
|
906
|
+
|
|
907
|
+
return RunContext(
|
|
908
|
+
run_id=run_id,
|
|
909
|
+
run_instance_id=run_instance_id,
|
|
910
|
+
run_tag=run_tag,
|
|
911
|
+
_store=self,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def _adopt_orphan_rows_inside_tx(
|
|
915
|
+
self, *, now: int, new_run_instance_id: int, run_id: str,
|
|
916
|
+
) -> int:
|
|
917
|
+
"""Re-point live orders + refs from previous instances onto the new one.
|
|
918
|
+
|
|
919
|
+
Caller owns the surrounding :meth:`transaction` block. Idempotent
|
|
920
|
+
for an empty input (no orphan rows → no-op). Every adopted COID
|
|
921
|
+
gets a per-row ``order_adopted`` audit event tied to the new
|
|
922
|
+
``run_instance_id`` (the new owner), with a payload carrying the
|
|
923
|
+
``prior_run_instance_id`` and ``prior_state`` for forensics.
|
|
924
|
+
|
|
925
|
+
Adoption only touches rows whose ``closed_ts_ms IS NULL`` — a
|
|
926
|
+
properly finalised order stays linked to the instance that
|
|
927
|
+
finalised it. ``order_refs`` rows for adopted COIDs follow the
|
|
928
|
+
same migration; refs for already-closed orders are left alone
|
|
929
|
+
(they will be cleaned up by the standard
|
|
930
|
+
:meth:`RunContext.close_order` path).
|
|
931
|
+
|
|
932
|
+
:return: Count of adopted COIDs (useful for tests / diagnostics).
|
|
933
|
+
"""
|
|
934
|
+
all_orphan_rows = self._conn.execute(
|
|
935
|
+
"SELECT o.run_instance_id AS prior_run_instance_id, "
|
|
936
|
+
" o.client_order_id, o.exchange_order_id, "
|
|
937
|
+
" o.intent_key, o.state "
|
|
938
|
+
"FROM orders o "
|
|
939
|
+
"JOIN runs r ON o.run_instance_id = r.run_instance_id "
|
|
940
|
+
"WHERE r.run_id = ? "
|
|
941
|
+
" AND o.run_instance_id != ? "
|
|
942
|
+
" AND o.closed_ts_ms IS NULL "
|
|
943
|
+
"ORDER BY o.run_instance_id DESC",
|
|
944
|
+
(run_id, new_run_instance_id),
|
|
945
|
+
).fetchall()
|
|
946
|
+
if not all_orphan_rows:
|
|
947
|
+
return 0
|
|
948
|
+
|
|
949
|
+
# Deduplicate by COID. Repeated crash/restart cycles before
|
|
950
|
+
# adoption existed could leave the same live ``client_order_id``
|
|
951
|
+
# under multiple ended ``run_instance_id``s of this ``run_id``.
|
|
952
|
+
# The PRIMARY KEY ``(run_instance_id, client_order_id)`` forbids
|
|
953
|
+
# collapsing them onto ``new_run_instance_id`` in a single UPDATE,
|
|
954
|
+
# so adopt only the most recent prior instance's row (the highest
|
|
955
|
+
# ``run_instance_id``) and terminalize the older duplicates with
|
|
956
|
+
# ``closed_ts_ms`` so they vanish from recovery's view.
|
|
957
|
+
adopted_rows: list = []
|
|
958
|
+
superseded_rows: list = []
|
|
959
|
+
seen_coids: set[str] = set()
|
|
960
|
+
for row in all_orphan_rows:
|
|
961
|
+
coid = row['client_order_id']
|
|
962
|
+
if coid in seen_coids:
|
|
963
|
+
superseded_rows.append(row)
|
|
964
|
+
else:
|
|
965
|
+
seen_coids.add(coid)
|
|
966
|
+
adopted_rows.append(row)
|
|
967
|
+
|
|
968
|
+
adopted_priors = sorted({row['prior_run_instance_id'] for row in adopted_rows})
|
|
969
|
+
adopted_coids = sorted({row['client_order_id'] for row in adopted_rows})
|
|
970
|
+
adopted_prior_placeholders = ','.join('?' * len(adopted_priors))
|
|
971
|
+
coid_placeholders = ','.join('?' * len(adopted_coids))
|
|
972
|
+
|
|
973
|
+
# Close superseded duplicates BEFORE migrating the canonical
|
|
974
|
+
# rows, so the UPDATE below cannot accidentally pick them up via
|
|
975
|
+
# the ``run_instance_id IN (...)`` predicate. Also drop their
|
|
976
|
+
# ``order_refs`` rows — leaving them attached to the closed prior
|
|
977
|
+
# instance would still collide with the canonical refs once they
|
|
978
|
+
# land on ``new_run_instance_id`` (PK includes ``ref_value``).
|
|
979
|
+
for row in superseded_rows:
|
|
980
|
+
self._conn.execute(
|
|
981
|
+
"UPDATE orders SET closed_ts_ms = ?, updated_ts_ms = ? "
|
|
982
|
+
"WHERE run_instance_id = ? AND client_order_id = ? "
|
|
983
|
+
" AND closed_ts_ms IS NULL",
|
|
984
|
+
(now, now,
|
|
985
|
+
row['prior_run_instance_id'], row['client_order_id']),
|
|
986
|
+
)
|
|
987
|
+
self._conn.execute(
|
|
988
|
+
"DELETE FROM order_refs "
|
|
989
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
990
|
+
(row['prior_run_instance_id'], row['client_order_id']),
|
|
991
|
+
)
|
|
992
|
+
self._conn.execute(
|
|
993
|
+
"INSERT INTO events ("
|
|
994
|
+
" run_instance_id, ts_ms, plugin_name, kind,"
|
|
995
|
+
" client_order_id, exchange_order_id, intent_key, payload"
|
|
996
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
997
|
+
(
|
|
998
|
+
new_run_instance_id, now, self._plugin_name,
|
|
999
|
+
'order_adopt_superseded',
|
|
1000
|
+
row['client_order_id'],
|
|
1001
|
+
row['exchange_order_id'],
|
|
1002
|
+
row['intent_key'],
|
|
1003
|
+
json.dumps({
|
|
1004
|
+
'prior_run_instance_id': row['prior_run_instance_id'],
|
|
1005
|
+
'prior_state': row['state'],
|
|
1006
|
+
}),
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
# Forensic only — the operator has nothing to do with this.
|
|
1010
|
+
# Full audit row lands in the ``events`` table as
|
|
1011
|
+
# ``order_adopt_superseded``.
|
|
1012
|
+
_log.debug(
|
|
1013
|
+
"broker storage: superseded orphan order coid=%r from "
|
|
1014
|
+
"run_instance_id=%d (state=%r) — newer prior instance "
|
|
1015
|
+
"exists for the same run_id; closed to resolve ambiguity",
|
|
1016
|
+
row['client_order_id'], row['prior_run_instance_id'],
|
|
1017
|
+
row['state'],
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
# Migrate the canonical orders rows.
|
|
1021
|
+
self._conn.execute(
|
|
1022
|
+
f"UPDATE orders SET run_instance_id = ?, updated_ts_ms = ? "
|
|
1023
|
+
f"WHERE run_instance_id IN ({adopted_prior_placeholders}) "
|
|
1024
|
+
f" AND client_order_id IN ({coid_placeholders}) "
|
|
1025
|
+
f" AND closed_ts_ms IS NULL",
|
|
1026
|
+
(new_run_instance_id, now, *adopted_priors, *adopted_coids),
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
# Migrate the order_refs rows for the same COIDs. order_refs has
|
|
1030
|
+
# PRIMARY KEY (run_instance_id, ref_type, ref_value) — colliding
|
|
1031
|
+
# refs from superseded duplicates would also fail the UPDATE, so
|
|
1032
|
+
# restrict the migration to refs belonging to the canonical prior
|
|
1033
|
+
# instances only.
|
|
1034
|
+
self._conn.execute(
|
|
1035
|
+
f"UPDATE order_refs SET run_instance_id = ? "
|
|
1036
|
+
f"WHERE run_instance_id IN ({adopted_prior_placeholders}) "
|
|
1037
|
+
f" AND client_order_id IN ({coid_placeholders})",
|
|
1038
|
+
(new_run_instance_id, *adopted_priors, *adopted_coids),
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
# Per-COID audit event under the NEW run_instance_id.
|
|
1042
|
+
for row in adopted_rows:
|
|
1043
|
+
self._conn.execute(
|
|
1044
|
+
"INSERT INTO events ("
|
|
1045
|
+
" run_instance_id, ts_ms, plugin_name, kind,"
|
|
1046
|
+
" client_order_id, exchange_order_id, intent_key, payload"
|
|
1047
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1048
|
+
(
|
|
1049
|
+
new_run_instance_id, now, self._plugin_name,
|
|
1050
|
+
'order_adopted',
|
|
1051
|
+
row['client_order_id'],
|
|
1052
|
+
row['exchange_order_id'],
|
|
1053
|
+
row['intent_key'],
|
|
1054
|
+
json.dumps({
|
|
1055
|
+
'prior_run_instance_id': row['prior_run_instance_id'],
|
|
1056
|
+
'prior_state': row['state'],
|
|
1057
|
+
}),
|
|
1058
|
+
),
|
|
1059
|
+
)
|
|
1060
|
+
_log.debug(
|
|
1061
|
+
"broker storage: adopted order coid=%r from "
|
|
1062
|
+
"run_instance_id=%d to %d (state=%r)",
|
|
1063
|
+
row['client_order_id'], row['prior_run_instance_id'],
|
|
1064
|
+
new_run_instance_id, row['state'],
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
# Summary INFO so the operator sees a single, actionable line on a
|
|
1068
|
+
# crash-recovery restart, while the per-row noise stays at DEBUG.
|
|
1069
|
+
if adopted_coids:
|
|
1070
|
+
_log.info(
|
|
1071
|
+
"broker storage: adopted %d order(s) from %d prior "
|
|
1072
|
+
"instance(s) of run_id; per-row details at DEBUG",
|
|
1073
|
+
len(adopted_coids), len(adopted_priors),
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
return len(adopted_coids)
|
|
1077
|
+
|
|
1078
|
+
def cleanup_stale_runs(
|
|
1079
|
+
self, *, stale_threshold_ms: int = STALE_THRESHOLD_MS,
|
|
1080
|
+
) -> int:
|
|
1081
|
+
"""Public stale-cleanup, callable manually (e.g. from debug CLI).
|
|
1082
|
+
|
|
1083
|
+
:param stale_threshold_ms: A heartbeat older than this counts as
|
|
1084
|
+
stale.
|
|
1085
|
+
:return: Number of rows closed.
|
|
1086
|
+
"""
|
|
1087
|
+
now = _now_ms()
|
|
1088
|
+
with self.transaction():
|
|
1089
|
+
return self._cleanup_stale_runs_inside_tx(
|
|
1090
|
+
now=now, stale_threshold_ms=stale_threshold_ms,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
def _cleanup_stale_runs_inside_tx(
|
|
1094
|
+
self, *, now: int, stale_threshold_ms: int = STALE_THRESHOLD_MS,
|
|
1095
|
+
) -> int:
|
|
1096
|
+
"""Stale-cleanup inside a transaction. Caller owns the :meth:`transaction` block.
|
|
1097
|
+
|
|
1098
|
+
Split from the public ``cleanup_stale_runs`` because
|
|
1099
|
+
``open_run`` calls this inside an already-open transaction — a
|
|
1100
|
+
nested :meth:`transaction` would open a block savepoint, adding
|
|
1101
|
+
complexity for no benefit here.
|
|
1102
|
+
"""
|
|
1103
|
+
threshold = now - stale_threshold_ms
|
|
1104
|
+
rows = self._conn.execute(
|
|
1105
|
+
"SELECT run_instance_id, last_heartbeat_ts_ms, run_id "
|
|
1106
|
+
"FROM runs WHERE ended_ts_ms IS NULL AND last_heartbeat_ts_ms < ?",
|
|
1107
|
+
(threshold,),
|
|
1108
|
+
).fetchall()
|
|
1109
|
+
for row in rows:
|
|
1110
|
+
rid = row['run_instance_id']
|
|
1111
|
+
last_hb = row['last_heartbeat_ts_ms']
|
|
1112
|
+
self._conn.execute(
|
|
1113
|
+
"UPDATE runs SET ended_ts_ms = ? WHERE run_instance_id = ?",
|
|
1114
|
+
(last_hb, rid),
|
|
1115
|
+
)
|
|
1116
|
+
self._conn.execute(
|
|
1117
|
+
"INSERT INTO events ("
|
|
1118
|
+
" run_instance_id, ts_ms, plugin_name, kind, payload"
|
|
1119
|
+
") VALUES (?, ?, ?, ?, ?)",
|
|
1120
|
+
(
|
|
1121
|
+
rid, now, self._plugin_name, 'stale_run_cleaned',
|
|
1122
|
+
json.dumps({
|
|
1123
|
+
'run_id': row['run_id'],
|
|
1124
|
+
'last_heartbeat_ts_ms': last_hb,
|
|
1125
|
+
'cleaned_at_ts_ms': now,
|
|
1126
|
+
}),
|
|
1127
|
+
),
|
|
1128
|
+
)
|
|
1129
|
+
_log.warning(
|
|
1130
|
+
"broker storage: stale run cleaned run_instance_id=%d run_id=%r "
|
|
1131
|
+
"last_heartbeat=%d", rid, row['run_id'], last_hb,
|
|
1132
|
+
)
|
|
1133
|
+
return len(rows)
|
|
1134
|
+
|
|
1135
|
+
def cleanup_old_data(self, retention_days: int = RETENTION_DAYS) -> int:
|
|
1136
|
+
"""Purge historical rows past the retention window.
|
|
1137
|
+
|
|
1138
|
+
Four deletions in one transaction:
|
|
1139
|
+
|
|
1140
|
+
1. ``events`` older than the cutoff. Two rows are protected
|
|
1141
|
+
regardless of age: events whose ``client_order_id`` matches a
|
|
1142
|
+
still-live order (the audit trail of an open position stays
|
|
1143
|
+
intact), and events whose ``intent_key`` still has a live
|
|
1144
|
+
envelope for the same logical ``run_id`` — the engine's
|
|
1145
|
+
startup replay (:meth:`RunContext.find_event_by_intent_key`,
|
|
1146
|
+
:meth:`RunContext.iter_events_by_kind_for_run_id`) reads
|
|
1147
|
+
those to dedup defensive-close FILLs across restarts.
|
|
1148
|
+
2. ``orders`` closed before the cutoff. Live rows are never
|
|
1149
|
+
touched.
|
|
1150
|
+
3. Orphan ``order_refs`` — rows whose order no longer exists.
|
|
1151
|
+
:meth:`RunContext.close_order` already trims refs eagerly;
|
|
1152
|
+
this catches rows left behind by crashes and by step 2.
|
|
1153
|
+
4. Ended ``runs`` rows older than the cutoff with no remaining
|
|
1154
|
+
child rows (orders / order_refs / events).
|
|
1155
|
+
|
|
1156
|
+
Freed pages are reused by SQLite, so the file stops growing
|
|
1157
|
+
even without VACUUM.
|
|
1158
|
+
|
|
1159
|
+
The spot inventory tables (``spot_executions``,
|
|
1160
|
+
``spot_inventory_epoch``, ``spot_asset_owner``) are exempt from
|
|
1161
|
+
retention by design: a spot position's reconstructibility must
|
|
1162
|
+
not expire while the position is open, however old its fills are.
|
|
1163
|
+
|
|
1164
|
+
:param retention_days: Rows older than this many days are
|
|
1165
|
+
eligible for purging.
|
|
1166
|
+
:return: Total number of deleted rows.
|
|
1167
|
+
"""
|
|
1168
|
+
cutoff = _now_ms() - retention_days * 86_400_000
|
|
1169
|
+
with self.transaction():
|
|
1170
|
+
deleted_events = self._conn.execute(
|
|
1171
|
+
"DELETE FROM events "
|
|
1172
|
+
"WHERE ts_ms < ? "
|
|
1173
|
+
" AND (client_order_id IS NULL OR NOT EXISTS ("
|
|
1174
|
+
" SELECT 1 FROM orders o "
|
|
1175
|
+
" WHERE o.client_order_id = events.client_order_id "
|
|
1176
|
+
" AND o.closed_ts_ms IS NULL)) "
|
|
1177
|
+
" AND NOT EXISTS ("
|
|
1178
|
+
" SELECT 1 FROM envelopes v "
|
|
1179
|
+
" JOIN runs r ON r.run_instance_id = events.run_instance_id "
|
|
1180
|
+
" WHERE v.run_id = r.run_id "
|
|
1181
|
+
" AND v.intent_key = events.intent_key)",
|
|
1182
|
+
(cutoff,),
|
|
1183
|
+
).rowcount
|
|
1184
|
+
deleted_orders = self._conn.execute(
|
|
1185
|
+
"DELETE FROM orders "
|
|
1186
|
+
"WHERE closed_ts_ms IS NOT NULL AND closed_ts_ms < ?",
|
|
1187
|
+
(cutoff,),
|
|
1188
|
+
).rowcount
|
|
1189
|
+
deleted_refs = self._conn.execute(
|
|
1190
|
+
"DELETE FROM order_refs "
|
|
1191
|
+
"WHERE NOT EXISTS ("
|
|
1192
|
+
" SELECT 1 FROM orders o "
|
|
1193
|
+
" WHERE o.run_instance_id = order_refs.run_instance_id "
|
|
1194
|
+
" AND o.client_order_id = order_refs.client_order_id)",
|
|
1195
|
+
).rowcount
|
|
1196
|
+
deleted_runs = self._conn.execute(
|
|
1197
|
+
"DELETE FROM runs "
|
|
1198
|
+
"WHERE ended_ts_ms IS NOT NULL AND ended_ts_ms < ? "
|
|
1199
|
+
" AND NOT EXISTS (SELECT 1 FROM orders o "
|
|
1200
|
+
" WHERE o.run_instance_id = runs.run_instance_id) "
|
|
1201
|
+
" AND NOT EXISTS (SELECT 1 FROM order_refs f "
|
|
1202
|
+
" WHERE f.run_instance_id = runs.run_instance_id) "
|
|
1203
|
+
" AND NOT EXISTS (SELECT 1 FROM events e "
|
|
1204
|
+
" WHERE e.run_instance_id = runs.run_instance_id)",
|
|
1205
|
+
(cutoff,),
|
|
1206
|
+
).rowcount
|
|
1207
|
+
total = deleted_events + deleted_orders + deleted_refs + deleted_runs
|
|
1208
|
+
if total:
|
|
1209
|
+
_log.info(
|
|
1210
|
+
"broker storage: retention purge removed %d row(s) "
|
|
1211
|
+
"(events=%d, orders=%d, order_refs=%d, runs=%d, "
|
|
1212
|
+
"retention=%d days)",
|
|
1213
|
+
total, deleted_events, deleted_orders, deleted_refs,
|
|
1214
|
+
deleted_runs, retention_days,
|
|
1215
|
+
)
|
|
1216
|
+
return total
|
|
1217
|
+
|
|
1218
|
+
def maybe_cleanup_old_data(self) -> None:
|
|
1219
|
+
"""Rate-limited retention purge for periodic callers.
|
|
1220
|
+
|
|
1221
|
+
Runs :meth:`cleanup_old_data` at most once per
|
|
1222
|
+
``PURGE_INTERVAL_MS``. The gate is stamped *before* the attempt
|
|
1223
|
+
and failures are logged and swallowed — retention is
|
|
1224
|
+
maintenance; it must never stop a live bot, and a persistent
|
|
1225
|
+
failure retries daily instead of every heartbeat.
|
|
1226
|
+
"""
|
|
1227
|
+
now = _now_ms()
|
|
1228
|
+
if now - self._last_purge_ms < PURGE_INTERVAL_MS:
|
|
1229
|
+
return
|
|
1230
|
+
self._last_purge_ms = now
|
|
1231
|
+
try:
|
|
1232
|
+
self.cleanup_old_data()
|
|
1233
|
+
except sqlite3.Error:
|
|
1234
|
+
_log.warning(
|
|
1235
|
+
"broker storage: retention purge failed; "
|
|
1236
|
+
"next attempt in %d ms", PURGE_INTERVAL_MS,
|
|
1237
|
+
exc_info=True,
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# === RunContext ============================================================
|
|
1242
|
+
|
|
1243
|
+
# noinspection PyProtectedMember
|
|
1244
|
+
@dataclass
|
|
1245
|
+
class RunContext:
|
|
1246
|
+
"""Context object for one concrete running run.
|
|
1247
|
+
|
|
1248
|
+
Every actual data movement goes through it. The ``run_instance_id``
|
|
1249
|
+
(physical FK) is stored here but not exposed on the caller surface —
|
|
1250
|
+
every method already filters on this run.
|
|
1251
|
+
|
|
1252
|
+
``close()`` is the happy-path teardown (``SIGINT`` / ``SIGTERM`` /
|
|
1253
|
+
context manager). The crash path is handled by the stale-cleanup
|
|
1254
|
+
that runs at the start of ``BrokerStore.open_run``.
|
|
1255
|
+
"""
|
|
1256
|
+
run_id: str
|
|
1257
|
+
run_instance_id: int
|
|
1258
|
+
run_tag: str
|
|
1259
|
+
_store: BrokerStore
|
|
1260
|
+
_last_heartbeat_write_ms: int = 0
|
|
1261
|
+
|
|
1262
|
+
# --- Composite writes ---------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
@contextlib.contextmanager
|
|
1265
|
+
def transaction(self) -> Iterator[sqlite3.Connection]:
|
|
1266
|
+
"""Open (or join) the store's serialized write transaction.
|
|
1267
|
+
|
|
1268
|
+
Passthrough to :meth:`BrokerStore.transaction` so composite
|
|
1269
|
+
writers holding only the run context can make several helper
|
|
1270
|
+
calls (``upsert_order`` + journal writes + ``close_order``)
|
|
1271
|
+
atomic: the helpers' own ``transaction()`` blocks nest into this
|
|
1272
|
+
span, and an exception anywhere rolls back all of it.
|
|
1273
|
+
"""
|
|
1274
|
+
with self._store.transaction() as conn:
|
|
1275
|
+
yield conn
|
|
1276
|
+
|
|
1277
|
+
# --- Core sync engine: envelope-identity ------------------------------
|
|
1278
|
+
|
|
1279
|
+
def record_envelope(
|
|
1280
|
+
self, key: str, bar_ts_ms: int, retry_seq: int,
|
|
1281
|
+
) -> None:
|
|
1282
|
+
"""Persist the first envelope for an ``intent_key``.
|
|
1283
|
+
|
|
1284
|
+
UPSERT on the ``(run_id, key)`` pair: ``run_id`` is the logical
|
|
1285
|
+
key, so every new instance inherits the previous envelopes of
|
|
1286
|
+
the same bot. Because of the sync engine's pinning semantics, a
|
|
1287
|
+
conflict only occurs on a retry_seq bump.
|
|
1288
|
+
"""
|
|
1289
|
+
now = _now_ms()
|
|
1290
|
+
with self._store.transaction():
|
|
1291
|
+
self._store._conn.execute(
|
|
1292
|
+
"INSERT INTO envelopes ("
|
|
1293
|
+
" run_id, intent_key, bar_ts_ms, retry_seq, updated_ts_ms"
|
|
1294
|
+
") VALUES (?, ?, ?, ?, ?) "
|
|
1295
|
+
"ON CONFLICT(run_id, intent_key) DO UPDATE SET "
|
|
1296
|
+
" bar_ts_ms = excluded.bar_ts_ms, "
|
|
1297
|
+
" retry_seq = excluded.retry_seq, "
|
|
1298
|
+
" updated_ts_ms = excluded.updated_ts_ms",
|
|
1299
|
+
(self.run_id, key, bar_ts_ms, retry_seq, now),
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
# noinspection SqlResolve
|
|
1303
|
+
def record_park(
|
|
1304
|
+
self, coid: str, key: str, *, kind: str = 'new',
|
|
1305
|
+
order_ids: list[str] | None = None,
|
|
1306
|
+
parked_ts_ms: int | None = None,
|
|
1307
|
+
) -> None:
|
|
1308
|
+
"""Persist a parked dispatch (unknown-disposition response).
|
|
1309
|
+
|
|
1310
|
+
On a re-park for the same ``(run_id, client_order_id)`` the
|
|
1311
|
+
``resolution`` column is also reset to ``NULL``: the row becomes
|
|
1312
|
+
parked again after a modify/retry timeout, so the *previous*
|
|
1313
|
+
attach/reject decision is now stale. Leaving an old
|
|
1314
|
+
``'attached'`` value in place would make the next restart's
|
|
1315
|
+
:meth:`OrderSyncEngine._consume_plugin_resolutions` immediately
|
|
1316
|
+
adopt the freshly parked dispatch (skipping the broker call) —
|
|
1317
|
+
exactly the wrong outcome, since the new park exists precisely
|
|
1318
|
+
because the exchange-side state is unknown.
|
|
1319
|
+
|
|
1320
|
+
:param coid: The dispatch's ``client_order_id`` — the broker-side
|
|
1321
|
+
idempotency key the park row is anchored on.
|
|
1322
|
+
:param key: The ``intent_key`` this parked dispatch belongs to.
|
|
1323
|
+
:param kind: ``'new'`` (default) when the parked dispatch was an
|
|
1324
|
+
``execute_*`` call (new order), ``'modify'`` when it was a
|
|
1325
|
+
``modify_entry`` / ``modify_exit``, ``'forced_cancel'`` when
|
|
1326
|
+
the row records an intent the engine decided to cancel whose
|
|
1327
|
+
cancel did not provably land (see
|
|
1328
|
+
``OrderSyncEngine._park_forced_cancel`` — these rows re-arm
|
|
1329
|
+
the forced-cancel retry after a restart and never enter the
|
|
1330
|
+
``get_open_orders`` matching path), ``'cancel_tentative'``
|
|
1331
|
+
when the row records a parent whose cancel disposition is
|
|
1332
|
+
unresolved (see
|
|
1333
|
+
``OrderSyncEngine._mark_intent_cancel_disposition_pending``
|
|
1334
|
+
— these rows re-arm the cancel-tentative retry loop after a
|
|
1335
|
+
restart independently of partial-bracket leg rows, and never
|
|
1336
|
+
enter the ``get_open_orders`` matching path either), or
|
|
1337
|
+
``'cancel_probe'`` for the speculative pre-park a SOFTWARE
|
|
1338
|
+
partial-bracket parent-entry cancel writes BEFORE the broker
|
|
1339
|
+
round-trip (see ``OrderSyncEngine._dispatch_cancel``). A
|
|
1340
|
+
``'cancel_probe'`` row exists only inside the crash window
|
|
1341
|
+
between the pre-park and the disposition landing: on a live
|
|
1342
|
+
path it is immediately reshaped to its final kind
|
|
1343
|
+
(``'cancel_tentative'`` on an unknown-disposition timeout,
|
|
1344
|
+
``'forced_cancel'`` on an ``execute_cancel`` ``False``, or
|
|
1345
|
+
deleted on a landed cancel). If a crash strands one, the
|
|
1346
|
+
restart rehydrates it through the OUTCOME-based
|
|
1347
|
+
cancel-tentative machine (``execute_cancel_with_outcome``,
|
|
1348
|
+
which distinguishes ALREADY_FILLED from CANCEL_CONFIRMED)
|
|
1349
|
+
instead of the bool-only forced-cancel retry — the latter
|
|
1350
|
+
would mistake a parent that filled during the window for a
|
|
1351
|
+
confirmed cancel and retire the live position's protection.
|
|
1352
|
+
The value is overwritten on re-park — a modify-park can be
|
|
1353
|
+
replaced by a later new-park and vice versa; the engine's
|
|
1354
|
+
cancel-probe-to-cancel-tentative / cancel-probe-to-forced-cancel
|
|
1355
|
+
ownership transfer relies on this UPDATE being atomic (a
|
|
1356
|
+
single row flips kind, no delete+insert window). The engine
|
|
1357
|
+
uses this when processing
|
|
1358
|
+
a ``'rejected'`` resolution to decide whether to clear the
|
|
1359
|
+
``_active_intents`` / ``_order_mapping`` slot (kind='new')
|
|
1360
|
+
or to keep the original mapping and only drop the envelope
|
|
1361
|
+
(kind='modify' — the original exchange order is still live).
|
|
1362
|
+
:param order_ids: The ``_order_mapping[key]`` snapshot captured at
|
|
1363
|
+
park time (the exchange order IDs), persisted as a JSON array
|
|
1364
|
+
so a post-restart modify-rejected resolution can recover them
|
|
1365
|
+
and avoid a duplicate ``execute_*`` dispatch. Defaults to an
|
|
1366
|
+
empty list.
|
|
1367
|
+
:param parked_ts_ms: Explicit park timestamp (epoch ms); defaults
|
|
1368
|
+
to now. ``'cancel_tentative'`` and ``'cancel_probe'`` rows pass
|
|
1369
|
+
the mark / pre-park time so the restart re-arm restores the
|
|
1370
|
+
original stale-grace deadline instead of granting a fresh
|
|
1371
|
+
window.
|
|
1372
|
+
"""
|
|
1373
|
+
if kind not in (
|
|
1374
|
+
'new', 'modify', 'forced_cancel',
|
|
1375
|
+
'cancel_tentative', 'cancel_probe',
|
|
1376
|
+
):
|
|
1377
|
+
raise ValueError(
|
|
1378
|
+
f"record_park: unknown kind {kind!r}; "
|
|
1379
|
+
f"expected 'new', 'modify', 'forced_cancel', "
|
|
1380
|
+
f"'cancel_tentative' or 'cancel_probe'"
|
|
1381
|
+
)
|
|
1382
|
+
now = _now_ms() if parked_ts_ms is None else parked_ts_ms
|
|
1383
|
+
ids_json = json.dumps(order_ids) if order_ids else '[]'
|
|
1384
|
+
with self._store.transaction():
|
|
1385
|
+
self._store._conn.execute(
|
|
1386
|
+
"INSERT INTO pending_verifications ("
|
|
1387
|
+
" run_id, client_order_id, intent_key, parked_ts_ms, "
|
|
1388
|
+
" dispatch_kind, order_ids"
|
|
1389
|
+
") VALUES (?, ?, ?, ?, ?, ?) "
|
|
1390
|
+
"ON CONFLICT(run_id, client_order_id) DO UPDATE SET "
|
|
1391
|
+
" intent_key = excluded.intent_key, "
|
|
1392
|
+
" parked_ts_ms = excluded.parked_ts_ms, "
|
|
1393
|
+
" resolution = NULL, "
|
|
1394
|
+
" dispatch_kind = excluded.dispatch_kind, "
|
|
1395
|
+
" order_ids = excluded.order_ids",
|
|
1396
|
+
(self.run_id, coid, key, now, kind, ids_json),
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
def record_unpark(self, coid: str) -> None:
|
|
1400
|
+
"""Remove a parked dispatch (it has shown up at the broker)."""
|
|
1401
|
+
with self._store.transaction():
|
|
1402
|
+
self._store._conn.execute(
|
|
1403
|
+
"DELETE FROM pending_verifications "
|
|
1404
|
+
"WHERE run_id = ? AND client_order_id = ?",
|
|
1405
|
+
(self.run_id, coid),
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
# noinspection SqlResolve
|
|
1409
|
+
def record_resolution(self, coid: str, resolution: str) -> None:
|
|
1410
|
+
"""Record a plugin-resolved disposition for a parked COID.
|
|
1411
|
+
|
|
1412
|
+
Used by plugins that can determine the parked dispatch's outcome
|
|
1413
|
+
through a path other than ``get_open_orders`` (e.g. a position
|
|
1414
|
+
snapshot). The engine consumes and deletes the row on the next
|
|
1415
|
+
sync.
|
|
1416
|
+
|
|
1417
|
+
``'rejected'`` is *sticky*: once a row is ``'rejected'`` a later
|
|
1418
|
+
``'attached'`` write does not overwrite it. The motivation is
|
|
1419
|
+
the Capital.com bracket scenario: TP and SL legs each call
|
|
1420
|
+
``record_resolution`` for the same parent COID (the bracket has
|
|
1421
|
+
a single park entry). If the TP is missing (``'rejected'``) and
|
|
1422
|
+
the SL is attached (``'attached'``), an order-dependent naive
|
|
1423
|
+
UPDATE could store ``'attached'`` last, the engine would keep
|
|
1424
|
+
the ExitIntent and never re-dispatch the TP, leaving protection
|
|
1425
|
+
permanently incomplete. ``'rejected'`` means "at least one leg
|
|
1426
|
+
is definitely missing" and always wins, because re-dispatch is
|
|
1427
|
+
idempotent (the already-attached leg is re-emitted with the
|
|
1428
|
+
same parameters and is a no-op at the broker).
|
|
1429
|
+
|
|
1430
|
+
:param coid: The parent ``client_order_id`` whose parked dispatch
|
|
1431
|
+
disposition is being recorded.
|
|
1432
|
+
:param resolution: ``'attached'`` if the dispatch landed at the
|
|
1433
|
+
broker (engine keeps the ``_active_intents`` entry),
|
|
1434
|
+
``'rejected'`` if it definitely did not (engine drops the
|
|
1435
|
+
intent so the next sync re-dispatches). Any other value
|
|
1436
|
+
raises ``ValueError``.
|
|
1437
|
+
"""
|
|
1438
|
+
if resolution not in ('attached', 'rejected'):
|
|
1439
|
+
raise ValueError(
|
|
1440
|
+
f"record_resolution: unknown resolution {resolution!r}; "
|
|
1441
|
+
f"expected 'attached' or 'rejected'"
|
|
1442
|
+
)
|
|
1443
|
+
with self._store.transaction():
|
|
1444
|
+
if resolution == 'attached':
|
|
1445
|
+
self._store._conn.execute(
|
|
1446
|
+
"UPDATE pending_verifications "
|
|
1447
|
+
"SET resolution = ? "
|
|
1448
|
+
"WHERE run_id = ? AND client_order_id = ? "
|
|
1449
|
+
" AND (resolution IS NULL OR resolution != 'rejected')",
|
|
1450
|
+
(resolution, self.run_id, coid),
|
|
1451
|
+
)
|
|
1452
|
+
else:
|
|
1453
|
+
self._store._conn.execute(
|
|
1454
|
+
"UPDATE pending_verifications "
|
|
1455
|
+
"SET resolution = ? "
|
|
1456
|
+
"WHERE run_id = ? AND client_order_id = ?",
|
|
1457
|
+
(resolution, self.run_id, coid),
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
def record_complete(self, key: str) -> None:
|
|
1461
|
+
"""Fully close an ``intent_key`` (cancel / close / rejected).
|
|
1462
|
+
|
|
1463
|
+
Atomically deletes the envelope and every parked dispatch
|
|
1464
|
+
attached to it within the ``run_id`` logical scope.
|
|
1465
|
+
"""
|
|
1466
|
+
with self._store.transaction():
|
|
1467
|
+
self._store._conn.execute(
|
|
1468
|
+
"DELETE FROM envelopes "
|
|
1469
|
+
"WHERE run_id = ? AND intent_key = ?",
|
|
1470
|
+
(self.run_id, key),
|
|
1471
|
+
)
|
|
1472
|
+
self._store._conn.execute(
|
|
1473
|
+
"DELETE FROM pending_verifications "
|
|
1474
|
+
"WHERE run_id = ? AND intent_key = ?",
|
|
1475
|
+
(self.run_id, key),
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
# noinspection SqlResolve
|
|
1479
|
+
def replay(
|
|
1480
|
+
self,
|
|
1481
|
+
) -> tuple[dict[str, EnvelopeRecord], dict[str, PendingRecord]]:
|
|
1482
|
+
"""Reconstruct the in-memory state after a restart.
|
|
1483
|
+
|
|
1484
|
+
Replays on the ``run_id`` logical key — a new instance inherits
|
|
1485
|
+
the previous envelopes and parked dispatches of the same logical
|
|
1486
|
+
bot.
|
|
1487
|
+
|
|
1488
|
+
:return: ``(envelopes_by_key, pending_by_coid)`` — same shape as
|
|
1489
|
+
the former ``state_store.replay`` returned.
|
|
1490
|
+
"""
|
|
1491
|
+
envelopes: dict[str, EnvelopeRecord] = {}
|
|
1492
|
+
pending: dict[str, PendingRecord] = {}
|
|
1493
|
+
|
|
1494
|
+
with self._store.read_lock() as conn:
|
|
1495
|
+
envelope_rows = conn.execute(
|
|
1496
|
+
"SELECT intent_key, bar_ts_ms, retry_seq FROM envelopes "
|
|
1497
|
+
"WHERE run_id = ?",
|
|
1498
|
+
(self.run_id,),
|
|
1499
|
+
).fetchall()
|
|
1500
|
+
pending_rows = conn.execute(
|
|
1501
|
+
"SELECT client_order_id, intent_key, resolution, "
|
|
1502
|
+
" dispatch_kind, order_ids, parked_ts_ms "
|
|
1503
|
+
"FROM pending_verifications "
|
|
1504
|
+
"WHERE run_id = ?",
|
|
1505
|
+
(self.run_id,),
|
|
1506
|
+
).fetchall()
|
|
1507
|
+
|
|
1508
|
+
for row in envelope_rows:
|
|
1509
|
+
envelopes[row['intent_key']] = EnvelopeRecord(
|
|
1510
|
+
key=row['intent_key'],
|
|
1511
|
+
bar_ts_ms=int(row['bar_ts_ms']),
|
|
1512
|
+
retry_seq=int(row['retry_seq']),
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
for row in pending_rows:
|
|
1516
|
+
raw_ids = row['order_ids'] or '[]'
|
|
1517
|
+
pending[row['client_order_id']] = PendingRecord(
|
|
1518
|
+
key=row['intent_key'],
|
|
1519
|
+
coid=row['client_order_id'],
|
|
1520
|
+
resolution=row['resolution'],
|
|
1521
|
+
dispatch_kind=row['dispatch_kind'] or 'new',
|
|
1522
|
+
order_ids=json.loads(raw_ids),
|
|
1523
|
+
parked_ts_ms=int(row['parked_ts_ms']),
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
return envelopes, pending
|
|
1527
|
+
|
|
1528
|
+
# noinspection SqlResolve
|
|
1529
|
+
def iter_pending_resolutions(self) -> list[PendingRecord]:
|
|
1530
|
+
"""Fetch parked rows that the plugin has already resolved.
|
|
1531
|
+
|
|
1532
|
+
Called by the engine's ``_verify_pending_dispatches`` at the
|
|
1533
|
+
start of every sync to learn which COIDs the plugin wrote a
|
|
1534
|
+
``record_resolution`` entry for. Returned records always have a
|
|
1535
|
+
non-``None`` ``resolution`` — still-parked (unresolved) rows are
|
|
1536
|
+
skipped.
|
|
1537
|
+
"""
|
|
1538
|
+
with self._store.read_lock() as conn:
|
|
1539
|
+
rows = conn.execute(
|
|
1540
|
+
"SELECT client_order_id, intent_key, resolution, "
|
|
1541
|
+
" dispatch_kind, order_ids, parked_ts_ms "
|
|
1542
|
+
"FROM pending_verifications "
|
|
1543
|
+
"WHERE run_id = ? AND resolution IS NOT NULL",
|
|
1544
|
+
(self.run_id,),
|
|
1545
|
+
).fetchall()
|
|
1546
|
+
return [
|
|
1547
|
+
PendingRecord(
|
|
1548
|
+
key=row['intent_key'],
|
|
1549
|
+
coid=row['client_order_id'],
|
|
1550
|
+
resolution=row['resolution'],
|
|
1551
|
+
dispatch_kind=row['dispatch_kind'] or 'new',
|
|
1552
|
+
order_ids=json.loads(row['order_ids'] or '[]'),
|
|
1553
|
+
parked_ts_ms=int(row['parked_ts_ms']),
|
|
1554
|
+
)
|
|
1555
|
+
for row in rows
|
|
1556
|
+
]
|
|
1557
|
+
|
|
1558
|
+
# --- Orders ------------------------------------------------------------
|
|
1559
|
+
|
|
1560
|
+
def upsert_order(
|
|
1561
|
+
self, client_order_id: str, **fields: Any,
|
|
1562
|
+
) -> None:
|
|
1563
|
+
"""UPSERT an order row — insert a new one or update an existing one.
|
|
1564
|
+
|
|
1565
|
+
Accepted fields: ``symbol``, ``side``, ``qty``, ``state``,
|
|
1566
|
+
``intent_key``, ``exchange_order_id``, ``from_entry``,
|
|
1567
|
+
``pine_entry_id``, ``sl_level``, ``tp_level``, ``trailing_stop``,
|
|
1568
|
+
``trailing_distance``, ``filled_qty``, ``extras``. Missing
|
|
1569
|
+
fields are filled with the DB defaults on insert; on update
|
|
1570
|
+
only the explicitly passed fields are written.
|
|
1571
|
+
|
|
1572
|
+
``extras`` is supplied as a dict and serialised to a JSON string.
|
|
1573
|
+
|
|
1574
|
+
:raises ValueError: When inserting a new row with required
|
|
1575
|
+
fields missing (``symbol``, ``side``, ``qty``, ``state``).
|
|
1576
|
+
"""
|
|
1577
|
+
now = _now_ms()
|
|
1578
|
+
extras = fields.pop('extras', None)
|
|
1579
|
+
extras_json = json.dumps(extras) if extras is not None else None
|
|
1580
|
+
|
|
1581
|
+
with self._store.transaction():
|
|
1582
|
+
existing = self._store._conn.execute(
|
|
1583
|
+
"SELECT 1 FROM orders "
|
|
1584
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1585
|
+
(self.run_instance_id, client_order_id),
|
|
1586
|
+
).fetchone()
|
|
1587
|
+
|
|
1588
|
+
if existing is None:
|
|
1589
|
+
required = ('symbol', 'side', 'qty', 'state')
|
|
1590
|
+
missing = [r for r in required if r not in fields]
|
|
1591
|
+
if missing:
|
|
1592
|
+
raise ValueError(
|
|
1593
|
+
f"upsert_order({client_order_id!r}) new row, "
|
|
1594
|
+
f"missing required fields: {missing}"
|
|
1595
|
+
)
|
|
1596
|
+
self._store._conn.execute(
|
|
1597
|
+
"INSERT INTO orders ("
|
|
1598
|
+
" run_instance_id, client_order_id, plugin_name,"
|
|
1599
|
+
" intent_key, exchange_order_id, symbol, side, qty,"
|
|
1600
|
+
" filled_qty, state, from_entry, pine_entry_id,"
|
|
1601
|
+
" sl_level, tp_level, trailing_stop, trailing_distance,"
|
|
1602
|
+
" created_ts_ms, updated_ts_ms, extras"
|
|
1603
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1604
|
+
(
|
|
1605
|
+
self.run_instance_id, client_order_id, self._store._plugin_name,
|
|
1606
|
+
fields.get('intent_key'), fields.get('exchange_order_id'),
|
|
1607
|
+
fields['symbol'], fields['side'], fields['qty'],
|
|
1608
|
+
fields.get('filled_qty', 0.0), fields['state'],
|
|
1609
|
+
fields.get('from_entry'), fields.get('pine_entry_id'),
|
|
1610
|
+
fields.get('sl_level'), fields.get('tp_level'),
|
|
1611
|
+
int(bool(fields.get('trailing_stop', False))),
|
|
1612
|
+
fields.get('trailing_distance'),
|
|
1613
|
+
now, now, extras_json,
|
|
1614
|
+
),
|
|
1615
|
+
)
|
|
1616
|
+
return
|
|
1617
|
+
|
|
1618
|
+
# Update path: only the explicitly passed fields are written.
|
|
1619
|
+
sets: list[str] = []
|
|
1620
|
+
params: list[Any] = []
|
|
1621
|
+
for col in (
|
|
1622
|
+
'intent_key', 'exchange_order_id', 'symbol', 'side', 'qty',
|
|
1623
|
+
'filled_qty', 'state', 'from_entry', 'pine_entry_id',
|
|
1624
|
+
'sl_level', 'tp_level', 'trailing_distance',
|
|
1625
|
+
):
|
|
1626
|
+
if col in fields:
|
|
1627
|
+
sets.append(f"{col} = ?")
|
|
1628
|
+
params.append(fields[col])
|
|
1629
|
+
if 'trailing_stop' in fields:
|
|
1630
|
+
sets.append("trailing_stop = ?")
|
|
1631
|
+
params.append(int(bool(fields['trailing_stop'])))
|
|
1632
|
+
if extras_json is not None:
|
|
1633
|
+
sets.append("extras = ?")
|
|
1634
|
+
params.append(extras_json)
|
|
1635
|
+
sets.append("updated_ts_ms = ?")
|
|
1636
|
+
params.append(now)
|
|
1637
|
+
params.extend([self.run_instance_id, client_order_id])
|
|
1638
|
+
|
|
1639
|
+
self._store._conn.execute(
|
|
1640
|
+
f"UPDATE orders SET {', '.join(sets)} "
|
|
1641
|
+
f"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1642
|
+
params,
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
def set_order_state(self, client_order_id: str, state: str) -> None:
|
|
1646
|
+
"""Single-field update: ``orders.state``."""
|
|
1647
|
+
self.upsert_order(client_order_id, state=state)
|
|
1648
|
+
|
|
1649
|
+
def set_exchange_id(
|
|
1650
|
+
self, client_order_id: str, exchange_order_id: str,
|
|
1651
|
+
) -> None:
|
|
1652
|
+
"""Populate ``orders.exchange_order_id`` (Capital.com confirm, IB orderId, ...)."""
|
|
1653
|
+
self.upsert_order(client_order_id, exchange_order_id=exchange_order_id)
|
|
1654
|
+
|
|
1655
|
+
def set_risk(
|
|
1656
|
+
self, client_order_id: str, *,
|
|
1657
|
+
sl: float | None = None,
|
|
1658
|
+
tp: float | None = None,
|
|
1659
|
+
trailing_stop: bool | None = None,
|
|
1660
|
+
trailing_distance: float | None = None,
|
|
1661
|
+
) -> None:
|
|
1662
|
+
"""Update SL/TP/trailing attributes in one go.
|
|
1663
|
+
|
|
1664
|
+
A ``None`` parameter *does not* erase the existing value — it
|
|
1665
|
+
just indicates that the caller is not setting it now. To clear
|
|
1666
|
+
a value, pass an explicit ``sl=0.0`` or use a dedicated UPDATE
|
|
1667
|
+
(no delete method exists yet — added if a real need arises).
|
|
1668
|
+
"""
|
|
1669
|
+
fields: dict[str, Any] = {}
|
|
1670
|
+
if sl is not None:
|
|
1671
|
+
fields['sl_level'] = sl
|
|
1672
|
+
if tp is not None:
|
|
1673
|
+
fields['tp_level'] = tp
|
|
1674
|
+
if trailing_stop is not None:
|
|
1675
|
+
fields['trailing_stop'] = trailing_stop
|
|
1676
|
+
if trailing_distance is not None:
|
|
1677
|
+
fields['trailing_distance'] = trailing_distance
|
|
1678
|
+
if fields:
|
|
1679
|
+
self.upsert_order(client_order_id, **fields)
|
|
1680
|
+
|
|
1681
|
+
def set_filled(self, client_order_id: str, filled_qty: float) -> None:
|
|
1682
|
+
"""Update ``orders.filled_qty`` (non-incremental — caller passes the full amount)."""
|
|
1683
|
+
self.upsert_order(client_order_id, filled_qty=filled_qty)
|
|
1684
|
+
|
|
1685
|
+
def reopen_order(self, client_order_id: str) -> None:
|
|
1686
|
+
"""Re-activate a previously closed order: ``closed_ts_ms = NULL``.
|
|
1687
|
+
|
|
1688
|
+
Typical use: a bracket leg row was closed by :meth:`close_order`
|
|
1689
|
+
on an earlier REJECTED attach (``state='rejected'``,
|
|
1690
|
+
``closed_ts_ms`` set), then a later ``modify_exit`` /
|
|
1691
|
+
``execute_exit`` re-attached a fresh protective leg at the
|
|
1692
|
+
broker under the same ``client_order_id`` — the row has to
|
|
1693
|
+
return to the live range (``iter_live_orders``, recovery,
|
|
1694
|
+
fill-fallback) or the post-persistence logic will not find it.
|
|
1695
|
+
|
|
1696
|
+
Reopen only nulls ``closed_ts_ms``; the caller is responsible
|
|
1697
|
+
for harmonising ``state`` and other fields via
|
|
1698
|
+
:meth:`upsert_order`. For audit purposes the reopen itself
|
|
1699
|
+
writes an event (``order_reopened``), so the full lifecycle
|
|
1700
|
+
remains traceable from the ``events`` table.
|
|
1701
|
+
|
|
1702
|
+
:raises: no specific error signalling. If the row does not
|
|
1703
|
+
exist or is no longer closed the SQL UPDATE affects zero
|
|
1704
|
+
rows and the method returns silently.
|
|
1705
|
+
"""
|
|
1706
|
+
now = _now_ms()
|
|
1707
|
+
with self._store.transaction():
|
|
1708
|
+
existing = self._store._conn.execute(
|
|
1709
|
+
"SELECT closed_ts_ms FROM orders "
|
|
1710
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1711
|
+
(self.run_instance_id, client_order_id),
|
|
1712
|
+
).fetchone()
|
|
1713
|
+
if existing is None or existing['closed_ts_ms'] is None:
|
|
1714
|
+
return
|
|
1715
|
+
self._store._conn.execute(
|
|
1716
|
+
"UPDATE orders SET closed_ts_ms = NULL, updated_ts_ms = ? "
|
|
1717
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1718
|
+
(now, self.run_instance_id, client_order_id),
|
|
1719
|
+
)
|
|
1720
|
+
self._store._conn.execute(
|
|
1721
|
+
"INSERT INTO events ("
|
|
1722
|
+
" run_instance_id, ts_ms, plugin_name, kind,"
|
|
1723
|
+
" client_order_id, exchange_order_id, intent_key, payload"
|
|
1724
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1725
|
+
(
|
|
1726
|
+
self.run_instance_id, now, self._store._plugin_name,
|
|
1727
|
+
'order_reopened', client_order_id, None, None, None,
|
|
1728
|
+
),
|
|
1729
|
+
)
|
|
1730
|
+
|
|
1731
|
+
def close_order(self, client_order_id: str) -> None:
|
|
1732
|
+
"""Close an order: set ``closed_ts_ms``, delete the related
|
|
1733
|
+
``order_refs`` rows and write an ``order_closed`` audit event in
|
|
1734
|
+
a single transaction.
|
|
1735
|
+
|
|
1736
|
+
Eagerly trimming ``order_refs`` keeps the table's size in line
|
|
1737
|
+
with the number of live orders. Historical dealReference /
|
|
1738
|
+
dealId lookups are served by the ``events`` table — that is why
|
|
1739
|
+
we also write an event here, carrying the ``exchange_order_id``
|
|
1740
|
+
valid at close time.
|
|
1741
|
+
"""
|
|
1742
|
+
now = _now_ms()
|
|
1743
|
+
with self._store.transaction():
|
|
1744
|
+
exchange_order_id: str | None = None
|
|
1745
|
+
row = self._store._conn.execute(
|
|
1746
|
+
"SELECT exchange_order_id FROM orders "
|
|
1747
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1748
|
+
(self.run_instance_id, client_order_id),
|
|
1749
|
+
).fetchone()
|
|
1750
|
+
if row is not None:
|
|
1751
|
+
exchange_order_id = row['exchange_order_id']
|
|
1752
|
+
self._store._conn.execute(
|
|
1753
|
+
"UPDATE orders SET closed_ts_ms = ?, updated_ts_ms = ? "
|
|
1754
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1755
|
+
(now, now, self.run_instance_id, client_order_id),
|
|
1756
|
+
)
|
|
1757
|
+
self._store._conn.execute(
|
|
1758
|
+
"DELETE FROM order_refs "
|
|
1759
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1760
|
+
(self.run_instance_id, client_order_id),
|
|
1761
|
+
)
|
|
1762
|
+
self._store._conn.execute(
|
|
1763
|
+
"INSERT INTO events ("
|
|
1764
|
+
" run_instance_id, ts_ms, plugin_name, kind,"
|
|
1765
|
+
" client_order_id, exchange_order_id, intent_key, payload"
|
|
1766
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1767
|
+
(
|
|
1768
|
+
self.run_instance_id, now, self._store._plugin_name,
|
|
1769
|
+
'order_closed', client_order_id, exchange_order_id,
|
|
1770
|
+
None, None,
|
|
1771
|
+
),
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
# --- Order refs (generic alias lookup) --------------------------------
|
|
1775
|
+
|
|
1776
|
+
def add_ref(
|
|
1777
|
+
self, client_order_id: str, ref_type: str, ref_value: str,
|
|
1778
|
+
) -> None:
|
|
1779
|
+
"""Record a broker-specific alias key.
|
|
1780
|
+
|
|
1781
|
+
E.g. Capital.com's ``deal_reference`` from the POST response,
|
|
1782
|
+
followed by ``deal_id`` from the confirm. For IB:
|
|
1783
|
+
``perm_id`` / ``order_id``. The
|
|
1784
|
+
``(run_instance_id, ref_type, ref_value)`` triplet is unique.
|
|
1785
|
+
"""
|
|
1786
|
+
now = _now_ms()
|
|
1787
|
+
with self._store.transaction():
|
|
1788
|
+
self._store._conn.execute(
|
|
1789
|
+
"INSERT INTO order_refs ("
|
|
1790
|
+
" run_instance_id, ref_type, ref_value, client_order_id, created_ts_ms"
|
|
1791
|
+
") VALUES (?, ?, ?, ?, ?) "
|
|
1792
|
+
"ON CONFLICT(run_instance_id, ref_type, ref_value) DO UPDATE SET "
|
|
1793
|
+
" client_order_id = excluded.client_order_id, "
|
|
1794
|
+
" created_ts_ms = excluded.created_ts_ms",
|
|
1795
|
+
(self.run_instance_id, ref_type, ref_value, client_order_id, now),
|
|
1796
|
+
)
|
|
1797
|
+
|
|
1798
|
+
def iter_refs_for_coid(
|
|
1799
|
+
self, client_order_id: str,
|
|
1800
|
+
) -> Iterator[tuple[str, str]]:
|
|
1801
|
+
"""Yield ``(ref_type, ref_value)`` pairs for one COID.
|
|
1802
|
+
|
|
1803
|
+
Used by recovery to materialise all alias keys that were
|
|
1804
|
+
durably recorded before a crash. The narrow but real crash
|
|
1805
|
+
window is between ``add_ref(deal_reference, ...)`` (commits
|
|
1806
|
+
the alias) and the subsequent ``upsert_order(extras={...})``
|
|
1807
|
+
that mirrors it into ``orders.extras``: in that gap the
|
|
1808
|
+
``deal_reference`` is only present in ``order_refs``, and the
|
|
1809
|
+
resume hook needs it to confirm the already-submitted order
|
|
1810
|
+
against the exchange.
|
|
1811
|
+
|
|
1812
|
+
Filtered by the current ``run_instance_id`` — adoption (see
|
|
1813
|
+
:meth:`BrokerStore.open_run`) already migrates orphan refs
|
|
1814
|
+
into the live instance, so this matches the row's owner.
|
|
1815
|
+
"""
|
|
1816
|
+
with self._store.read_lock() as conn:
|
|
1817
|
+
rows = conn.execute(
|
|
1818
|
+
"SELECT ref_type, ref_value FROM order_refs "
|
|
1819
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1820
|
+
(self.run_instance_id, client_order_id),
|
|
1821
|
+
).fetchall()
|
|
1822
|
+
for row in rows:
|
|
1823
|
+
yield row['ref_type'], row['ref_value']
|
|
1824
|
+
|
|
1825
|
+
def find_by_ref(
|
|
1826
|
+
self, ref_type: str, ref_value: str,
|
|
1827
|
+
) -> OrderRow | None:
|
|
1828
|
+
"""Alias-based order lookup in O(log n).
|
|
1829
|
+
|
|
1830
|
+
Joins ``order_refs`` × ``orders`` on the PK. A single indexed
|
|
1831
|
+
SELECT that reduces this use case to one DB call.
|
|
1832
|
+
"""
|
|
1833
|
+
with self._store.read_lock() as conn:
|
|
1834
|
+
row = conn.execute(
|
|
1835
|
+
"SELECT o.* FROM orders o "
|
|
1836
|
+
"JOIN order_refs r ON "
|
|
1837
|
+
" r.run_instance_id = o.run_instance_id "
|
|
1838
|
+
" AND r.client_order_id = o.client_order_id "
|
|
1839
|
+
"WHERE r.run_instance_id = ? AND r.ref_type = ? AND r.ref_value = ?",
|
|
1840
|
+
(self.run_instance_id, ref_type, ref_value),
|
|
1841
|
+
).fetchone()
|
|
1842
|
+
return _row_to_order(row) if row is not None else None
|
|
1843
|
+
|
|
1844
|
+
# --- Queries ----------------------------------------------------------
|
|
1845
|
+
|
|
1846
|
+
def get_order(self, client_order_id: str) -> OrderRow | None:
|
|
1847
|
+
"""Direct lookup by CO-ID."""
|
|
1848
|
+
with self._store.read_lock() as conn:
|
|
1849
|
+
row = conn.execute(
|
|
1850
|
+
"SELECT * FROM orders "
|
|
1851
|
+
"WHERE run_instance_id = ? AND client_order_id = ?",
|
|
1852
|
+
(self.run_instance_id, client_order_id),
|
|
1853
|
+
).fetchone()
|
|
1854
|
+
return _row_to_order(row) if row is not None else None
|
|
1855
|
+
|
|
1856
|
+
def iter_live_orders(
|
|
1857
|
+
self, *,
|
|
1858
|
+
symbol: str | None = None,
|
|
1859
|
+
from_entry: str | None = None,
|
|
1860
|
+
) -> Iterator[OrderRow]:
|
|
1861
|
+
"""Iterator over live (not yet closed) orders.
|
|
1862
|
+
|
|
1863
|
+
The partial index (``idx_orders_live``) serves the filter; a
|
|
1864
|
+
realistic one-way Pine strategy has fewer than 50 live rows at
|
|
1865
|
+
any time, so the query cost is negligible.
|
|
1866
|
+
"""
|
|
1867
|
+
sql = (
|
|
1868
|
+
"SELECT * FROM orders "
|
|
1869
|
+
"WHERE run_instance_id = ? AND closed_ts_ms IS NULL"
|
|
1870
|
+
)
|
|
1871
|
+
params: list[Any] = [self.run_instance_id]
|
|
1872
|
+
if symbol is not None:
|
|
1873
|
+
sql += " AND symbol = ?"
|
|
1874
|
+
params.append(symbol)
|
|
1875
|
+
if from_entry is not None:
|
|
1876
|
+
sql += " AND from_entry = ?"
|
|
1877
|
+
params.append(from_entry)
|
|
1878
|
+
with self._store.read_lock() as conn:
|
|
1879
|
+
rows = conn.execute(sql, params).fetchall()
|
|
1880
|
+
for row in rows:
|
|
1881
|
+
yield _row_to_order(row)
|
|
1882
|
+
|
|
1883
|
+
def foreign_live_exchange_order_ids(self, *, symbol: str) -> set[str]:
|
|
1884
|
+
"""Return physical order ids claimed by another run on this account.
|
|
1885
|
+
|
|
1886
|
+
Ended run instances remain authoritative owners while their order row is
|
|
1887
|
+
still live. This lets a restarting sibling distinguish genuinely
|
|
1888
|
+
untracked venue exposure from a position opened by another run label.
|
|
1889
|
+
|
|
1890
|
+
:param symbol: Canonical broker symbol to scope the ownership query.
|
|
1891
|
+
:return: Non-empty exchange order ids owned outside this run instance.
|
|
1892
|
+
"""
|
|
1893
|
+
with self._store.read_lock() as conn:
|
|
1894
|
+
rows = conn.execute(
|
|
1895
|
+
"SELECT DISTINCT owned.exchange_order_id "
|
|
1896
|
+
"FROM orders owned "
|
|
1897
|
+
"JOIN runs owner_run "
|
|
1898
|
+
" ON owner_run.run_instance_id = owned.run_instance_id "
|
|
1899
|
+
"JOIN runs current_run "
|
|
1900
|
+
" ON current_run.run_instance_id = ? "
|
|
1901
|
+
"WHERE owned.run_instance_id != ? "
|
|
1902
|
+
" AND owned.closed_ts_ms IS NULL "
|
|
1903
|
+
" AND owned.exchange_order_id IS NOT NULL "
|
|
1904
|
+
" AND owned.exchange_order_id != '' "
|
|
1905
|
+
" AND owned.symbol = ? "
|
|
1906
|
+
" AND owner_run.account_id = current_run.account_id "
|
|
1907
|
+
" AND owner_run.plugin_name = current_run.plugin_name",
|
|
1908
|
+
(self.run_instance_id, self.run_instance_id, symbol),
|
|
1909
|
+
).fetchall()
|
|
1910
|
+
return {str(row['exchange_order_id']) for row in rows}
|
|
1911
|
+
|
|
1912
|
+
# --- Events -----------------------------------------------------------
|
|
1913
|
+
|
|
1914
|
+
def log_event(
|
|
1915
|
+
self, kind: str, *,
|
|
1916
|
+
client_order_id: str | None = None,
|
|
1917
|
+
exchange_order_id: str | None = None,
|
|
1918
|
+
intent_key: str | None = None,
|
|
1919
|
+
payload: dict | None = None,
|
|
1920
|
+
) -> None:
|
|
1921
|
+
"""Write an audit event.
|
|
1922
|
+
|
|
1923
|
+
``payload`` is serialised to JSON; plugin-specific fields can
|
|
1924
|
+
be added freely.
|
|
1925
|
+
"""
|
|
1926
|
+
now = _now_ms()
|
|
1927
|
+
payload_json = json.dumps(payload) if payload is not None else None
|
|
1928
|
+
with self._store.transaction():
|
|
1929
|
+
self._store._conn.execute(
|
|
1930
|
+
"INSERT INTO events ("
|
|
1931
|
+
" run_instance_id, ts_ms, plugin_name, kind,"
|
|
1932
|
+
" client_order_id, exchange_order_id, intent_key, payload"
|
|
1933
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1934
|
+
(
|
|
1935
|
+
self.run_instance_id, now, self._store._plugin_name, kind,
|
|
1936
|
+
client_order_id, exchange_order_id, intent_key, payload_json,
|
|
1937
|
+
),
|
|
1938
|
+
)
|
|
1939
|
+
|
|
1940
|
+
def find_event_by_intent_key(
|
|
1941
|
+
self, intent_key: str, kind: str,
|
|
1942
|
+
) -> bool:
|
|
1943
|
+
"""Return ``True`` iff at least one event with the given
|
|
1944
|
+
``intent_key`` and ``kind`` exists for this *logical* run.
|
|
1945
|
+
|
|
1946
|
+
Scoped to ``run_id``, not ``run_instance_id`` — the engine's
|
|
1947
|
+
startup replay uses this to detect whether a defensive-close
|
|
1948
|
+
FILL event was recorded in a *previous* process instance (whose
|
|
1949
|
+
adopted orders carry over into the current run). A query
|
|
1950
|
+
scoped to the current ``run_instance_id`` would always miss
|
|
1951
|
+
cross-restart settlements and re-arm markers that are already
|
|
1952
|
+
done.
|
|
1953
|
+
|
|
1954
|
+
The JOIN cost is amortised over the rare set of startup-replay
|
|
1955
|
+
invocations; the ``runs.run_id`` lookup uses the existing
|
|
1956
|
+
``idx_runs_run_id`` index.
|
|
1957
|
+
"""
|
|
1958
|
+
with self._store.read_lock() as conn:
|
|
1959
|
+
row = conn.execute(
|
|
1960
|
+
"SELECT 1 FROM events AS e "
|
|
1961
|
+
"JOIN runs AS r ON e.run_instance_id = r.run_instance_id "
|
|
1962
|
+
"WHERE r.run_id = ? AND e.intent_key = ? AND e.kind = ? "
|
|
1963
|
+
"LIMIT 1",
|
|
1964
|
+
(self.run_id, intent_key, kind),
|
|
1965
|
+
).fetchone()
|
|
1966
|
+
return row is not None
|
|
1967
|
+
|
|
1968
|
+
def iter_events_by_kind_since(
|
|
1969
|
+
self, kind: str, since_ts_ms: int,
|
|
1970
|
+
) -> Iterator[dict]:
|
|
1971
|
+
"""Iterate event payloads of a given ``kind`` since ``since_ts_ms``.
|
|
1972
|
+
|
|
1973
|
+
ASC by ``ts_ms``; only payloads that JSON-deserialise
|
|
1974
|
+
successfully are yielded (empty or malformed payloads are
|
|
1975
|
+
skipped). Used by plugin-side cross-restart recovery (e.g.
|
|
1976
|
+
activity-cursor rebuild) so the persisted audit-event tail can
|
|
1977
|
+
be read without dropping down to raw SQL.
|
|
1978
|
+
|
|
1979
|
+
:param kind: Filter on ``events.kind``.
|
|
1980
|
+
:param since_ts_ms: Lower bound on ``ts_ms`` (inclusive).
|
|
1981
|
+
:return: Iterator of payload dicts in insertion order.
|
|
1982
|
+
"""
|
|
1983
|
+
with self._store.read_lock() as conn:
|
|
1984
|
+
rows = conn.execute(
|
|
1985
|
+
"SELECT payload FROM events "
|
|
1986
|
+
"WHERE run_instance_id = ? AND kind = ? AND ts_ms >= ? "
|
|
1987
|
+
"ORDER BY ts_ms",
|
|
1988
|
+
(self.run_instance_id, kind, since_ts_ms),
|
|
1989
|
+
).fetchall()
|
|
1990
|
+
for row in rows:
|
|
1991
|
+
raw = row['payload']
|
|
1992
|
+
if not raw:
|
|
1993
|
+
continue
|
|
1994
|
+
try:
|
|
1995
|
+
yield json.loads(raw)
|
|
1996
|
+
except ValueError:
|
|
1997
|
+
continue
|
|
1998
|
+
|
|
1999
|
+
def iter_events_by_kind_for_run_id(
|
|
2000
|
+
self, kind: str,
|
|
2001
|
+
) -> Iterator[tuple[str | None, str | None, str | None, dict]]:
|
|
2002
|
+
"""Iterate events of a given ``kind`` across every run instance
|
|
2003
|
+
sharing this logical ``run_id``.
|
|
2004
|
+
|
|
2005
|
+
Scoped to ``runs.run_id`` (not ``run_instance_id``) — the engine's
|
|
2006
|
+
startup replay uses this to recover dedup state from
|
|
2007
|
+
``defensive_close_filled`` audit events written by prior process
|
|
2008
|
+
instances. The ``find_event_by_intent_key`` helper only answers
|
|
2009
|
+
"does any matching event exist?"; this iterator returns the
|
|
2010
|
+
identifying columns + payload so callers can reseed in-memory
|
|
2011
|
+
caches.
|
|
2012
|
+
|
|
2013
|
+
Yields ``(intent_key, client_order_id, exchange_order_id,
|
|
2014
|
+
payload_dict)`` tuples in insertion order. Rows with malformed
|
|
2015
|
+
payloads yield an empty dict (the column data is still
|
|
2016
|
+
useful).
|
|
2017
|
+
"""
|
|
2018
|
+
with self._store.read_lock() as conn:
|
|
2019
|
+
rows = conn.execute(
|
|
2020
|
+
"SELECT e.intent_key, e.client_order_id, e.exchange_order_id, "
|
|
2021
|
+
" e.payload "
|
|
2022
|
+
"FROM events AS e "
|
|
2023
|
+
"JOIN runs AS r ON e.run_instance_id = r.run_instance_id "
|
|
2024
|
+
"WHERE r.run_id = ? AND e.kind = ? "
|
|
2025
|
+
"ORDER BY e.ts_ms",
|
|
2026
|
+
(self.run_id, kind),
|
|
2027
|
+
).fetchall()
|
|
2028
|
+
for row in rows:
|
|
2029
|
+
raw = row['payload']
|
|
2030
|
+
payload: dict = {}
|
|
2031
|
+
if raw:
|
|
2032
|
+
try:
|
|
2033
|
+
parsed = json.loads(raw)
|
|
2034
|
+
except ValueError:
|
|
2035
|
+
parsed = None
|
|
2036
|
+
if isinstance(parsed, dict):
|
|
2037
|
+
payload = parsed
|
|
2038
|
+
yield (
|
|
2039
|
+
row['intent_key'],
|
|
2040
|
+
row['client_order_id'],
|
|
2041
|
+
row['exchange_order_id'],
|
|
2042
|
+
payload,
|
|
2043
|
+
)
|
|
2044
|
+
|
|
2045
|
+
# --- Spot inventory: execution ledger ----------------------------------
|
|
2046
|
+
|
|
2047
|
+
# noinspection SqlResolve
|
|
2048
|
+
def record_spot_execution(
|
|
2049
|
+
self, account_id: str, product_id: str, *,
|
|
2050
|
+
fill_id: str,
|
|
2051
|
+
side: str,
|
|
2052
|
+
base_delta: str,
|
|
2053
|
+
quote_delta: str,
|
|
2054
|
+
price: str,
|
|
2055
|
+
fee_amount: str,
|
|
2056
|
+
fee_currency: str,
|
|
2057
|
+
ts_ms: int,
|
|
2058
|
+
venue_seq: int | None = None,
|
|
2059
|
+
exchange_order_id: str | None = None,
|
|
2060
|
+
client_order_id: str | None = None,
|
|
2061
|
+
delivered: bool = False,
|
|
2062
|
+
) -> bool:
|
|
2063
|
+
"""Append one venue execution to the spot ledger.
|
|
2064
|
+
|
|
2065
|
+
Idempotent on the ``(account_id, product_id, fill_id)`` primary
|
|
2066
|
+
key: re-recording an already-known fill (overlapping catch-up
|
|
2067
|
+
window, PUSH replay, restart) is a no-op and returns ``False``.
|
|
2068
|
+
The row is stamped with this context's logical ``run_id`` — the
|
|
2069
|
+
PK deliberately excludes it, so the same venue execution can
|
|
2070
|
+
never be booked under two logical runs; use
|
|
2071
|
+
:meth:`spot_execution_owner` to inspect a conflicting row.
|
|
2072
|
+
|
|
2073
|
+
Numeric parameters are canonical decimal strings produced by
|
|
2074
|
+
:mod:`~pynecore.core.broker.spot_inventory` — the storage layer
|
|
2075
|
+
stores them verbatim.
|
|
2076
|
+
|
|
2077
|
+
:param account_id: The plugin's authenticated account id (fill-id
|
|
2078
|
+
uniqueness dimension).
|
|
2079
|
+
:param product_id: Venue product identifier the fill belongs to.
|
|
2080
|
+
:param fill_id: The venue's execution id — the dedup key.
|
|
2081
|
+
:param side: ``'buy'`` or ``'sell'``.
|
|
2082
|
+
:param base_delta: Signed base-asset delta (canonical decimal string).
|
|
2083
|
+
:param quote_delta: Signed quote-asset delta (canonical decimal string).
|
|
2084
|
+
:param price: Fill price (canonical decimal string).
|
|
2085
|
+
:param fee_amount: Fee amount (canonical decimal string).
|
|
2086
|
+
:param fee_currency: Currency the fee was charged in.
|
|
2087
|
+
:param ts_ms: Venue execution timestamp (ms).
|
|
2088
|
+
:param venue_seq: The venue's monotonic execution-sequence number
|
|
2089
|
+
when it exposes one, else ``None``. Used only as a tiebreak
|
|
2090
|
+
in the fold ordering; a venue whose fills can share a
|
|
2091
|
+
millisecond MUST provide it or same-ms buy/sell pairs may
|
|
2092
|
+
replay reversed.
|
|
2093
|
+
:param exchange_order_id: Broker order ref, when known.
|
|
2094
|
+
:param client_order_id: Bot client-order-id, when the fill maps
|
|
2095
|
+
to a bot dispatch.
|
|
2096
|
+
:param delivered: ``True`` when the caller hands the fill to the
|
|
2097
|
+
sync engine in the same transaction (live outbox flip);
|
|
2098
|
+
``False`` for catch-up rows that the next startup adoption
|
|
2099
|
+
folds into the synthesized position.
|
|
2100
|
+
:return: ``True`` if the row was inserted, ``False`` on dedup.
|
|
2101
|
+
"""
|
|
2102
|
+
if side not in ('buy', 'sell'):
|
|
2103
|
+
raise ValueError(
|
|
2104
|
+
f"record_spot_execution: unknown side {side!r}; "
|
|
2105
|
+
f"expected 'buy' or 'sell'"
|
|
2106
|
+
)
|
|
2107
|
+
with self._store.transaction():
|
|
2108
|
+
cur = self._store._conn.execute(
|
|
2109
|
+
"INSERT INTO spot_executions ("
|
|
2110
|
+
" run_id, account_id, product_id, fill_id,"
|
|
2111
|
+
" exchange_order_id, client_order_id, side,"
|
|
2112
|
+
" base_delta, quote_delta, price, fee_amount, fee_currency,"
|
|
2113
|
+
" ts_ms, venue_seq, delivered"
|
|
2114
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
2115
|
+
"ON CONFLICT(account_id, product_id, fill_id) DO NOTHING",
|
|
2116
|
+
(
|
|
2117
|
+
self.run_id, account_id, product_id, fill_id,
|
|
2118
|
+
exchange_order_id, client_order_id, side,
|
|
2119
|
+
base_delta, quote_delta, price, fee_amount, fee_currency,
|
|
2120
|
+
ts_ms, venue_seq, int(delivered),
|
|
2121
|
+
),
|
|
2122
|
+
)
|
|
2123
|
+
return cur.rowcount == 1
|
|
2124
|
+
|
|
2125
|
+
# noinspection SqlResolve
|
|
2126
|
+
def spot_execution_owner(
|
|
2127
|
+
self, account_id: str, product_id: str, fill_id: str,
|
|
2128
|
+
) -> str | None:
|
|
2129
|
+
"""Return the ``run_id`` that owns a ledger row, or ``None``.
|
|
2130
|
+
|
|
2131
|
+
Used after a dedup'd :meth:`record_spot_execution` to distinguish
|
|
2132
|
+
the benign case (this run already recorded the fill) from the
|
|
2133
|
+
ownership conflict (another logical run booked it first).
|
|
2134
|
+
"""
|
|
2135
|
+
with self._store.read_lock() as conn:
|
|
2136
|
+
row = conn.execute(
|
|
2137
|
+
"SELECT run_id FROM spot_executions "
|
|
2138
|
+
"WHERE account_id = ? AND product_id = ? AND fill_id = ?",
|
|
2139
|
+
(account_id, product_id, fill_id),
|
|
2140
|
+
).fetchone()
|
|
2141
|
+
return None if row is None else row['run_id']
|
|
2142
|
+
|
|
2143
|
+
# noinspection SqlResolve
|
|
2144
|
+
def iter_spot_executions(
|
|
2145
|
+
self, account_id: str, product_id: str, *,
|
|
2146
|
+
undelivered_only: bool = False,
|
|
2147
|
+
) -> list[SpotExecutionRow]:
|
|
2148
|
+
"""Fetch this logical run's ledger rows, oldest first.
|
|
2149
|
+
|
|
2150
|
+
Deterministic order: ``(ts_ms, venue_seq, fill_id)`` — venue
|
|
2151
|
+
timestamps can tie, so the venue's own execution-sequence key
|
|
2152
|
+
(``COALESCE``d to 0 when absent) is the primary tiebreak and the
|
|
2153
|
+
fill-id the last resort. This keeps the inventory fold replayable
|
|
2154
|
+
and prevents a same-millisecond buy/sell pair from reordering
|
|
2155
|
+
into a false oversell. Returns a list (not a lazy iterator) so
|
|
2156
|
+
the read lock is not held while the caller processes rows.
|
|
2157
|
+
"""
|
|
2158
|
+
sql = (
|
|
2159
|
+
"SELECT * FROM spot_executions "
|
|
2160
|
+
"WHERE run_id = ? AND account_id = ? AND product_id = ?"
|
|
2161
|
+
)
|
|
2162
|
+
if undelivered_only:
|
|
2163
|
+
sql += " AND delivered = 0"
|
|
2164
|
+
sql += " ORDER BY ts_ms, COALESCE(venue_seq, 0), fill_id"
|
|
2165
|
+
with self._store.read_lock() as conn:
|
|
2166
|
+
rows = conn.execute(
|
|
2167
|
+
sql, (self.run_id, account_id, product_id),
|
|
2168
|
+
).fetchall()
|
|
2169
|
+
return [_row_to_spot_execution(row) for row in rows]
|
|
2170
|
+
|
|
2171
|
+
# noinspection SqlResolve
|
|
2172
|
+
def mark_spot_executions_delivered(
|
|
2173
|
+
self, account_id: str, product_id: str,
|
|
2174
|
+
fill_ids: list[str] | None = None,
|
|
2175
|
+
) -> int:
|
|
2176
|
+
"""Flip the ``delivered`` outbox marker on ledger rows.
|
|
2177
|
+
|
|
2178
|
+
:param account_id: The plugin's authenticated account id.
|
|
2179
|
+
:param product_id: Venue product identifier.
|
|
2180
|
+
:param fill_ids: The rows to flip; ``None`` flips every
|
|
2181
|
+
undelivered row of this logical run (the startup-adoption
|
|
2182
|
+
watermark: the synthesized position the engine adopts already
|
|
2183
|
+
folds them, so they must never be re-delivered as events).
|
|
2184
|
+
:return: Number of rows flipped.
|
|
2185
|
+
"""
|
|
2186
|
+
base_sql = (
|
|
2187
|
+
"UPDATE spot_executions SET delivered = 1 "
|
|
2188
|
+
"WHERE run_id = ? AND account_id = ? AND product_id = ? "
|
|
2189
|
+
" AND delivered = 0"
|
|
2190
|
+
)
|
|
2191
|
+
with self._store.transaction():
|
|
2192
|
+
if fill_ids is None:
|
|
2193
|
+
return self._store._conn.execute(
|
|
2194
|
+
base_sql, (self.run_id, account_id, product_id),
|
|
2195
|
+
).rowcount
|
|
2196
|
+
flipped = 0
|
|
2197
|
+
# Chunked IN-lists — SQLite's bound-variable budget is finite.
|
|
2198
|
+
for start in range(0, len(fill_ids), 500):
|
|
2199
|
+
chunk = fill_ids[start:start + 500]
|
|
2200
|
+
placeholders = ','.join('?' * len(chunk))
|
|
2201
|
+
flipped += self._store._conn.execute(
|
|
2202
|
+
f"{base_sql} AND fill_id IN ({placeholders})",
|
|
2203
|
+
(self.run_id, account_id, product_id, *chunk),
|
|
2204
|
+
).rowcount
|
|
2205
|
+
return flipped
|
|
2206
|
+
|
|
2207
|
+
# --- Spot inventory: epoch ---------------------------------------------
|
|
2208
|
+
|
|
2209
|
+
# noinspection SqlResolve
|
|
2210
|
+
def get_latest_spot_epoch(self, product_id: str) -> SpotEpochRow | None:
|
|
2211
|
+
"""Fetch this logical run's newest epoch row for a product."""
|
|
2212
|
+
with self._store.read_lock() as conn:
|
|
2213
|
+
row = conn.execute(
|
|
2214
|
+
"SELECT * FROM spot_inventory_epoch "
|
|
2215
|
+
"WHERE run_id = ? AND product_id = ? "
|
|
2216
|
+
"ORDER BY epoch_seq DESC LIMIT 1",
|
|
2217
|
+
(self.run_id, product_id),
|
|
2218
|
+
).fetchone()
|
|
2219
|
+
return None if row is None else _row_to_spot_epoch(row)
|
|
2220
|
+
|
|
2221
|
+
# noinspection SqlResolve
|
|
2222
|
+
def insert_spot_epoch(
|
|
2223
|
+
self, *,
|
|
2224
|
+
account_id: str,
|
|
2225
|
+
base_asset: str,
|
|
2226
|
+
product_id: str,
|
|
2227
|
+
foreign_baseline: str,
|
|
2228
|
+
cursor_scope: str | None,
|
|
2229
|
+
exec_cursor: str | None,
|
|
2230
|
+
state: str = 'active',
|
|
2231
|
+
) -> SpotEpochRow:
|
|
2232
|
+
"""Insert the next epoch generation for a product.
|
|
2233
|
+
|
|
2234
|
+
``epoch_seq`` continues from this run's newest existing epoch
|
|
2235
|
+
(1 for the first). Runs inside the caller's transaction when one
|
|
2236
|
+
is open — the rebaseline path relies on this to make "write new
|
|
2237
|
+
epoch + activate" a single atomic span.
|
|
2238
|
+
|
|
2239
|
+
:return: The freshly inserted row.
|
|
2240
|
+
"""
|
|
2241
|
+
_validate_spot_epoch_state(state)
|
|
2242
|
+
now = _now_ms()
|
|
2243
|
+
with self._store.transaction():
|
|
2244
|
+
row = self._store._conn.execute(
|
|
2245
|
+
"SELECT COALESCE(MAX(epoch_seq), 0) AS seq "
|
|
2246
|
+
"FROM spot_inventory_epoch "
|
|
2247
|
+
"WHERE run_id = ? AND product_id = ?",
|
|
2248
|
+
(self.run_id, product_id),
|
|
2249
|
+
).fetchone()
|
|
2250
|
+
epoch_seq = int(row['seq']) + 1
|
|
2251
|
+
self._store._conn.execute(
|
|
2252
|
+
"INSERT INTO spot_inventory_epoch ("
|
|
2253
|
+
" run_id, plugin_name, account_id, base_asset, product_id,"
|
|
2254
|
+
" epoch_seq, foreign_baseline, cursor_scope, exec_cursor,"
|
|
2255
|
+
" state, created_ts_ms"
|
|
2256
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
2257
|
+
(
|
|
2258
|
+
self.run_id, self._store._plugin_name, account_id,
|
|
2259
|
+
base_asset, product_id, epoch_seq, foreign_baseline,
|
|
2260
|
+
cursor_scope, exec_cursor, state, now,
|
|
2261
|
+
),
|
|
2262
|
+
)
|
|
2263
|
+
return SpotEpochRow(
|
|
2264
|
+
plugin_name=self._store._plugin_name,
|
|
2265
|
+
account_id=account_id,
|
|
2266
|
+
base_asset=base_asset,
|
|
2267
|
+
product_id=product_id,
|
|
2268
|
+
epoch_seq=epoch_seq,
|
|
2269
|
+
foreign_baseline=foreign_baseline,
|
|
2270
|
+
cursor_scope=cursor_scope,
|
|
2271
|
+
exec_cursor=exec_cursor,
|
|
2272
|
+
state=state,
|
|
2273
|
+
created_ts_ms=now,
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
# noinspection SqlResolve
|
|
2277
|
+
def set_spot_epoch_state(
|
|
2278
|
+
self, product_id: str, epoch_seq: int, state: str,
|
|
2279
|
+
) -> None:
|
|
2280
|
+
"""Update one epoch row's lifecycle state."""
|
|
2281
|
+
_validate_spot_epoch_state(state)
|
|
2282
|
+
with self._store.transaction():
|
|
2283
|
+
self._store._conn.execute(
|
|
2284
|
+
"UPDATE spot_inventory_epoch SET state = ? "
|
|
2285
|
+
"WHERE run_id = ? AND product_id = ? AND epoch_seq = ?",
|
|
2286
|
+
(state, self.run_id, product_id, epoch_seq),
|
|
2287
|
+
)
|
|
2288
|
+
|
|
2289
|
+
# noinspection SqlResolve
|
|
2290
|
+
def set_spot_epoch_cursor(
|
|
2291
|
+
self, product_id: str, epoch_seq: int, exec_cursor: str | None,
|
|
2292
|
+
) -> None:
|
|
2293
|
+
"""Advance the durable execution-history cursor.
|
|
2294
|
+
|
|
2295
|
+
MUST be called inside the same transaction that recorded every
|
|
2296
|
+
execution before the new cursor position (the caller opens the
|
|
2297
|
+
span; this joins it) — a cursor ahead of the recorded ledger
|
|
2298
|
+
would silently skip fills on the next catch-up.
|
|
2299
|
+
"""
|
|
2300
|
+
with self._store.transaction():
|
|
2301
|
+
self._store._conn.execute(
|
|
2302
|
+
"UPDATE spot_inventory_epoch SET exec_cursor = ? "
|
|
2303
|
+
"WHERE run_id = ? AND product_id = ? AND epoch_seq = ?",
|
|
2304
|
+
(exec_cursor, self.run_id, product_id, epoch_seq),
|
|
2305
|
+
)
|
|
2306
|
+
|
|
2307
|
+
# noinspection SqlResolve
|
|
2308
|
+
def set_spot_epoch_pending_conflict(
|
|
2309
|
+
self, product_id: str, epoch_seq: int, *,
|
|
2310
|
+
ts_ms: int | None,
|
|
2311
|
+
payload: dict | None = None,
|
|
2312
|
+
) -> None:
|
|
2313
|
+
"""Persist (or clear) the runtime settlement-grace conflict state.
|
|
2314
|
+
|
|
2315
|
+
A balance-invariant mismatch first observed at runtime arms this
|
|
2316
|
+
marker instead of quarantining immediately — settlement lag is a
|
|
2317
|
+
*temporal* state, not a numeric tolerance. Persisting it keeps
|
|
2318
|
+
the grace clock monotonic across crashes: a crash loop cannot
|
|
2319
|
+
keep resetting the window and mask a real drift. ``ts_ms=None``
|
|
2320
|
+
clears the marker (the invariant reconciled).
|
|
2321
|
+
"""
|
|
2322
|
+
payload_json = json.dumps(payload) if payload is not None else None
|
|
2323
|
+
with self._store.transaction():
|
|
2324
|
+
self._store._conn.execute(
|
|
2325
|
+
"UPDATE spot_inventory_epoch "
|
|
2326
|
+
"SET pending_conflict_ts_ms = ?, pending_conflict = ? "
|
|
2327
|
+
"WHERE run_id = ? AND product_id = ? AND epoch_seq = ?",
|
|
2328
|
+
(ts_ms, payload_json, self.run_id, product_id, epoch_seq),
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
# --- Spot inventory: asset-ownership lease ------------------------------
|
|
2332
|
+
|
|
2333
|
+
# noinspection SqlResolve
|
|
2334
|
+
def claim_spot_asset(
|
|
2335
|
+
self, account_id: str, base_asset: str, quote_asset: str, *,
|
|
2336
|
+
stale_threshold_ms: int = STALE_THRESHOLD_MS,
|
|
2337
|
+
) -> bool:
|
|
2338
|
+
"""Claim (or refresh) the exclusive base-asset lease for this run.
|
|
2339
|
+
|
|
2340
|
+
One active logical run per ``(plugin, account, base_asset)``.
|
|
2341
|
+
The lease is keyed on the PHYSICAL ``run_instance_id`` (``run_id``
|
|
2342
|
+
is reused across restarts by design). Own-instance re-claim
|
|
2343
|
+
refreshes the heartbeat; a lease held by a DIFFERENT instance is
|
|
2344
|
+
taken over only when that instance is no longer live — its
|
|
2345
|
+
``runs`` row ended cleanly (a normal restart handing off) or its
|
|
2346
|
+
lease heartbeat went stale (a crash). A prior instance that is
|
|
2347
|
+
still live keeps the lease, so the claimant starts quarantined.
|
|
2348
|
+
|
|
2349
|
+
Two guards this enforces:
|
|
2350
|
+
|
|
2351
|
+
- **Physical-instance fencing.** A resumed zombie carries the
|
|
2352
|
+
same ``run_id`` as the replacement instance that already took
|
|
2353
|
+
its lease, but a different ``run_instance_id``. Keying on the
|
|
2354
|
+
instance (and checking the prior instance's ``runs`` liveness,
|
|
2355
|
+
not just the lease heartbeat, so a quick clean restart is not
|
|
2356
|
+
mistaken for a live conflict) means the zombie cannot reclaim,
|
|
2357
|
+
and its next :meth:`heartbeat_spot_asset` reports the loss.
|
|
2358
|
+
- **Base-vs-quote exclusivity.** A live foreign run that trades
|
|
2359
|
+
the shared asset as its quote cash (or owns this run's quote
|
|
2360
|
+
as its base) would silently move this run's balance invariant.
|
|
2361
|
+
Such an overlap fails the claim up front instead of surfacing
|
|
2362
|
+
later as a spurious conflict quarantine.
|
|
2363
|
+
|
|
2364
|
+
A cross-``run_id`` takeover writes a ``spot_lease_taken_over``
|
|
2365
|
+
audit event; a same-``run_id`` restart adopts its predecessor's
|
|
2366
|
+
lease silently. The exclusion is local to this SQLite file by
|
|
2367
|
+
design; a second instance on another workdir/machine is detected
|
|
2368
|
+
by the balance invariant, not prevented here.
|
|
2369
|
+
|
|
2370
|
+
:return: ``True`` when this run holds the lease on return.
|
|
2371
|
+
"""
|
|
2372
|
+
now = _now_ms()
|
|
2373
|
+
live_after = now - stale_threshold_ms
|
|
2374
|
+
# IMMEDIATE: the base-vs-quote overlap check-then-insert must be
|
|
2375
|
+
# atomic even across separate connections to the same store file;
|
|
2376
|
+
# a DEFERRED span would let two claimants both pass the pre-write
|
|
2377
|
+
# overlap read (verified) before either writes its row.
|
|
2378
|
+
with self._store.immediate_transaction():
|
|
2379
|
+
# Base-vs-quote overlap: any LIVE foreign lease that uses our
|
|
2380
|
+
# base as its quote, or owns our quote as its base, shares an
|
|
2381
|
+
# asset with us and must block the claim.
|
|
2382
|
+
overlap = self._store._conn.execute(
|
|
2383
|
+
"SELECT run_id FROM spot_asset_owner "
|
|
2384
|
+
"WHERE plugin_name = ? AND account_id = ? "
|
|
2385
|
+
" AND run_instance_id != ? AND heartbeat_ts_ms > ? "
|
|
2386
|
+
" AND (quote_asset = ? OR base_asset = ?)",
|
|
2387
|
+
(self._store._plugin_name, account_id,
|
|
2388
|
+
self.run_instance_id, live_after,
|
|
2389
|
+
base_asset, quote_asset),
|
|
2390
|
+
).fetchone()
|
|
2391
|
+
if overlap is not None:
|
|
2392
|
+
return False
|
|
2393
|
+
row = self._store._conn.execute(
|
|
2394
|
+
"SELECT run_id, run_instance_id, heartbeat_ts_ms "
|
|
2395
|
+
"FROM spot_asset_owner "
|
|
2396
|
+
"WHERE plugin_name = ? AND account_id = ? AND base_asset = ?",
|
|
2397
|
+
(self._store._plugin_name, account_id, base_asset),
|
|
2398
|
+
).fetchone()
|
|
2399
|
+
if row is None:
|
|
2400
|
+
cur = self._store._conn.execute(
|
|
2401
|
+
"INSERT INTO spot_asset_owner ("
|
|
2402
|
+
" plugin_name, account_id, base_asset, quote_asset,"
|
|
2403
|
+
" run_id, run_instance_id, claimed_ts_ms, heartbeat_ts_ms"
|
|
2404
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
|
2405
|
+
"ON CONFLICT(plugin_name, account_id, base_asset) "
|
|
2406
|
+
"DO NOTHING",
|
|
2407
|
+
(self._store._plugin_name, account_id, base_asset,
|
|
2408
|
+
quote_asset, self.run_id, self.run_instance_id, now, now),
|
|
2409
|
+
)
|
|
2410
|
+
return cur.rowcount == 1
|
|
2411
|
+
prior_instance = int(row['run_instance_id'])
|
|
2412
|
+
if prior_instance == self.run_instance_id:
|
|
2413
|
+
self._store._conn.execute(
|
|
2414
|
+
"UPDATE spot_asset_owner SET heartbeat_ts_ms = ? "
|
|
2415
|
+
"WHERE plugin_name = ? AND account_id = ? "
|
|
2416
|
+
" AND base_asset = ? AND run_instance_id = ?",
|
|
2417
|
+
(now, self._store._plugin_name, account_id,
|
|
2418
|
+
base_asset, self.run_instance_id),
|
|
2419
|
+
)
|
|
2420
|
+
return True
|
|
2421
|
+
# A different physical instance holds it. Take over only if
|
|
2422
|
+
# that instance is no longer live: its ``runs`` row ended
|
|
2423
|
+
# cleanly (normal restart handoff), was cleaned up, or its
|
|
2424
|
+
# lease heartbeat went stale (crash). A live prior instance —
|
|
2425
|
+
# a genuine concurrent run, or a resumed zombie sharing our
|
|
2426
|
+
# run_id — keeps the lease.
|
|
2427
|
+
prior_run = self._store._conn.execute(
|
|
2428
|
+
"SELECT ended_ts_ms FROM runs WHERE run_instance_id = ?",
|
|
2429
|
+
(prior_instance,),
|
|
2430
|
+
).fetchone()
|
|
2431
|
+
prior_ended = prior_run is None or prior_run['ended_ts_ms'] is not None
|
|
2432
|
+
lease_stale = now - int(row['heartbeat_ts_ms']) > stale_threshold_ms
|
|
2433
|
+
if not (prior_ended or lease_stale):
|
|
2434
|
+
return False
|
|
2435
|
+
# Guarded takeover: the WHERE re-checks the observed instance
|
|
2436
|
+
# and heartbeat so a concurrent refresh by the (actually
|
|
2437
|
+
# live) holder makes this a zero-row no-op instead of a steal.
|
|
2438
|
+
cur = self._store._conn.execute(
|
|
2439
|
+
"UPDATE spot_asset_owner "
|
|
2440
|
+
"SET run_id = ?, run_instance_id = ?, quote_asset = ?,"
|
|
2441
|
+
" claimed_ts_ms = ?, heartbeat_ts_ms = ? "
|
|
2442
|
+
"WHERE plugin_name = ? AND account_id = ? AND base_asset = ? "
|
|
2443
|
+
" AND run_instance_id = ? AND heartbeat_ts_ms = ?",
|
|
2444
|
+
(self.run_id, self.run_instance_id, quote_asset, now, now,
|
|
2445
|
+
self._store._plugin_name, account_id, base_asset,
|
|
2446
|
+
prior_instance, row['heartbeat_ts_ms']),
|
|
2447
|
+
)
|
|
2448
|
+
if cur.rowcount != 1:
|
|
2449
|
+
return False
|
|
2450
|
+
# A same-run_id restart adopts its predecessor's lease
|
|
2451
|
+
# silently; only a cross-run_id takeover is an audit event.
|
|
2452
|
+
if row['run_id'] != self.run_id:
|
|
2453
|
+
self._store._conn.execute(
|
|
2454
|
+
"INSERT INTO events ("
|
|
2455
|
+
" run_instance_id, ts_ms, plugin_name, kind, payload"
|
|
2456
|
+
") VALUES (?, ?, ?, ?, ?)",
|
|
2457
|
+
(
|
|
2458
|
+
self.run_instance_id, now, self._store._plugin_name,
|
|
2459
|
+
'spot_lease_taken_over',
|
|
2460
|
+
json.dumps({
|
|
2461
|
+
'account_id': account_id,
|
|
2462
|
+
'base_asset': base_asset,
|
|
2463
|
+
'prior_run_id': row['run_id'],
|
|
2464
|
+
'prior_run_instance_id': prior_instance,
|
|
2465
|
+
'prior_heartbeat_ts_ms': int(row['heartbeat_ts_ms']),
|
|
2466
|
+
}),
|
|
2467
|
+
),
|
|
2468
|
+
)
|
|
2469
|
+
_log.warning(
|
|
2470
|
+
"broker storage: spot asset lease taken over "
|
|
2471
|
+
"(account=%r base=%r prior_run_id=%r prior_instance=%d "
|
|
2472
|
+
"heartbeat=%d ended=%s)",
|
|
2473
|
+
account_id, base_asset, row['run_id'],
|
|
2474
|
+
prior_instance, int(row['heartbeat_ts_ms']), prior_ended,
|
|
2475
|
+
)
|
|
2476
|
+
return True
|
|
2477
|
+
|
|
2478
|
+
# noinspection SqlResolve
|
|
2479
|
+
def heartbeat_spot_asset(self, account_id: str, base_asset: str) -> bool:
|
|
2480
|
+
"""Refresh this run's lease heartbeat.
|
|
2481
|
+
|
|
2482
|
+
Guarded by the physical ``run_instance_id``: a resumed zombie
|
|
2483
|
+
whose lease a replacement instance already took over updates zero
|
|
2484
|
+
rows and gets ``False`` back, so the caller can quarantine
|
|
2485
|
+
instead of trading on a lease it no longer holds.
|
|
2486
|
+
|
|
2487
|
+
:return: ``True`` when this instance still holds the lease.
|
|
2488
|
+
"""
|
|
2489
|
+
now = _now_ms()
|
|
2490
|
+
with self._store.transaction():
|
|
2491
|
+
cur = self._store._conn.execute(
|
|
2492
|
+
"UPDATE spot_asset_owner SET heartbeat_ts_ms = ? "
|
|
2493
|
+
"WHERE plugin_name = ? AND account_id = ? "
|
|
2494
|
+
" AND base_asset = ? AND run_instance_id = ?",
|
|
2495
|
+
(now, self._store._plugin_name, account_id,
|
|
2496
|
+
base_asset, self.run_instance_id),
|
|
2497
|
+
)
|
|
2498
|
+
return cur.rowcount == 1
|
|
2499
|
+
|
|
2500
|
+
# noinspection SqlResolve
|
|
2501
|
+
def release_spot_asset(self, account_id: str, base_asset: str) -> None:
|
|
2502
|
+
"""Release this run's lease on a clean shutdown.
|
|
2503
|
+
|
|
2504
|
+
Only this physical instance's own row is deleted; a lease another
|
|
2505
|
+
instance took over in the meantime is left alone.
|
|
2506
|
+
"""
|
|
2507
|
+
with self._store.transaction():
|
|
2508
|
+
self._store._conn.execute(
|
|
2509
|
+
"DELETE FROM spot_asset_owner "
|
|
2510
|
+
"WHERE plugin_name = ? AND account_id = ? "
|
|
2511
|
+
" AND base_asset = ? AND run_instance_id = ?",
|
|
2512
|
+
(self._store._plugin_name, account_id,
|
|
2513
|
+
base_asset, self.run_instance_id),
|
|
2514
|
+
)
|
|
2515
|
+
|
|
2516
|
+
# --- Lifecycle --------------------------------------------------------
|
|
2517
|
+
|
|
2518
|
+
def heartbeat(self) -> None:
|
|
2519
|
+
"""Heartbeat for the current run. Rate-limited to ``HEARTBEAT_INTERVAL_MS``.
|
|
2520
|
+
|
|
2521
|
+
The caller can call this every sync cycle — the internal gate
|
|
2522
|
+
ensures at most one UPDATE per minute. Does NOT run stale-run
|
|
2523
|
+
cleanup (that is exclusively ``open_run()``'s responsibility —
|
|
2524
|
+
clear separation of concerns); it does trigger the daily
|
|
2525
|
+
retention purge (see :meth:`BrokerStore.maybe_cleanup_old_data`).
|
|
2526
|
+
"""
|
|
2527
|
+
now = _now_ms()
|
|
2528
|
+
if now - self._last_heartbeat_write_ms < HEARTBEAT_INTERVAL_MS:
|
|
2529
|
+
return
|
|
2530
|
+
with self._store.transaction():
|
|
2531
|
+
self._store._conn.execute(
|
|
2532
|
+
"UPDATE runs SET last_heartbeat_ts_ms = ? "
|
|
2533
|
+
"WHERE run_instance_id = ?",
|
|
2534
|
+
(now, self.run_instance_id),
|
|
2535
|
+
)
|
|
2536
|
+
self._last_heartbeat_write_ms = now
|
|
2537
|
+
# Daily retention purge rides on the heartbeat cadence — a
|
|
2538
|
+
# months-running bot never revisits ``open_run()``, so this is
|
|
2539
|
+
# what keeps the events/orders tables bounded while live.
|
|
2540
|
+
self._store.maybe_cleanup_old_data()
|
|
2541
|
+
|
|
2542
|
+
def close(self) -> None:
|
|
2543
|
+
"""Happy-path run teardown: populate ``ended_ts_ms``.
|
|
2544
|
+
|
|
2545
|
+
Repeated calls are no-ops (after the first UPDATE
|
|
2546
|
+
``ended_ts_ms`` is non-NULL and the WHERE clause excludes the
|
|
2547
|
+
row). SIGKILL is handled by stale-cleanup; this method is only
|
|
2548
|
+
the controlled-shutdown path.
|
|
2549
|
+
"""
|
|
2550
|
+
now = _now_ms()
|
|
2551
|
+
with self._store.transaction():
|
|
2552
|
+
self._store._conn.execute(
|
|
2553
|
+
"UPDATE runs SET ended_ts_ms = ?, last_heartbeat_ts_ms = ? "
|
|
2554
|
+
"WHERE run_instance_id = ? AND ended_ts_ms IS NULL",
|
|
2555
|
+
(now, now, self.run_instance_id),
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2558
|
+
def __enter__(self) -> 'RunContext':
|
|
2559
|
+
return self
|
|
2560
|
+
|
|
2561
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
2562
|
+
self.close()
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
# === Private helpers =======================================================
|
|
2566
|
+
|
|
2567
|
+
_SPOT_EPOCH_STATES = ('active', 'quarantined', 'closed')
|
|
2568
|
+
|
|
2569
|
+
|
|
2570
|
+
def _validate_spot_epoch_state(state: str) -> None:
|
|
2571
|
+
if state not in _SPOT_EPOCH_STATES:
|
|
2572
|
+
raise ValueError(
|
|
2573
|
+
f"spot epoch state must be one of {_SPOT_EPOCH_STATES}, "
|
|
2574
|
+
f"got {state!r}"
|
|
2575
|
+
)
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def _row_to_spot_execution(row: sqlite3.Row) -> SpotExecutionRow:
|
|
2579
|
+
return SpotExecutionRow(
|
|
2580
|
+
fill_id=row['fill_id'],
|
|
2581
|
+
exchange_order_id=row['exchange_order_id'],
|
|
2582
|
+
client_order_id=row['client_order_id'],
|
|
2583
|
+
side=row['side'],
|
|
2584
|
+
base_delta=row['base_delta'],
|
|
2585
|
+
quote_delta=row['quote_delta'],
|
|
2586
|
+
price=row['price'],
|
|
2587
|
+
fee_amount=row['fee_amount'],
|
|
2588
|
+
fee_currency=row['fee_currency'],
|
|
2589
|
+
ts_ms=int(row['ts_ms']),
|
|
2590
|
+
delivered=bool(row['delivered']),
|
|
2591
|
+
venue_seq=(
|
|
2592
|
+
None if row['venue_seq'] is None else int(row['venue_seq'])
|
|
2593
|
+
),
|
|
2594
|
+
)
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
def _row_to_spot_epoch(row: sqlite3.Row) -> SpotEpochRow:
|
|
2598
|
+
raw_conflict = row['pending_conflict']
|
|
2599
|
+
conflict: dict | None = None
|
|
2600
|
+
if raw_conflict:
|
|
2601
|
+
try:
|
|
2602
|
+
parsed = json.loads(raw_conflict)
|
|
2603
|
+
except ValueError:
|
|
2604
|
+
parsed = None
|
|
2605
|
+
if isinstance(parsed, dict):
|
|
2606
|
+
conflict = parsed
|
|
2607
|
+
return SpotEpochRow(
|
|
2608
|
+
plugin_name=row['plugin_name'],
|
|
2609
|
+
account_id=row['account_id'],
|
|
2610
|
+
base_asset=row['base_asset'],
|
|
2611
|
+
product_id=row['product_id'],
|
|
2612
|
+
epoch_seq=int(row['epoch_seq']),
|
|
2613
|
+
foreign_baseline=row['foreign_baseline'],
|
|
2614
|
+
cursor_scope=row['cursor_scope'],
|
|
2615
|
+
exec_cursor=row['exec_cursor'],
|
|
2616
|
+
state=row['state'],
|
|
2617
|
+
created_ts_ms=int(row['created_ts_ms']),
|
|
2618
|
+
pending_conflict_ts_ms=(
|
|
2619
|
+
None if row['pending_conflict_ts_ms'] is None
|
|
2620
|
+
else int(row['pending_conflict_ts_ms'])
|
|
2621
|
+
),
|
|
2622
|
+
pending_conflict=conflict,
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
def _row_to_order(row: sqlite3.Row) -> OrderRow:
|
|
2627
|
+
"""Convert ``sqlite3.Row`` → :class:`OrderRow`, parsing extras JSON."""
|
|
2628
|
+
extras_raw = row['extras']
|
|
2629
|
+
extras: dict = json.loads(extras_raw) if extras_raw else {}
|
|
2630
|
+
return OrderRow(
|
|
2631
|
+
client_order_id=row['client_order_id'],
|
|
2632
|
+
plugin_name=row['plugin_name'],
|
|
2633
|
+
intent_key=row['intent_key'],
|
|
2634
|
+
exchange_order_id=row['exchange_order_id'],
|
|
2635
|
+
symbol=row['symbol'],
|
|
2636
|
+
side=row['side'],
|
|
2637
|
+
qty=float(row['qty']),
|
|
2638
|
+
filled_qty=float(row['filled_qty'] or 0.0),
|
|
2639
|
+
state=row['state'],
|
|
2640
|
+
from_entry=row['from_entry'],
|
|
2641
|
+
pine_entry_id=row['pine_entry_id'],
|
|
2642
|
+
sl_level=None if row['sl_level'] is None else float(row['sl_level']),
|
|
2643
|
+
tp_level=None if row['tp_level'] is None else float(row['tp_level']),
|
|
2644
|
+
trailing_stop=bool(row['trailing_stop']),
|
|
2645
|
+
trailing_distance=(
|
|
2646
|
+
None if row['trailing_distance'] is None
|
|
2647
|
+
else float(row['trailing_distance'])
|
|
2648
|
+
),
|
|
2649
|
+
created_ts_ms=int(row['created_ts_ms']),
|
|
2650
|
+
updated_ts_ms=int(row['updated_ts_ms']),
|
|
2651
|
+
closed_ts_ms=(
|
|
2652
|
+
None if row['closed_ts_ms'] is None else int(row['closed_ts_ms'])
|
|
2653
|
+
),
|
|
2654
|
+
extras=extras,
|
|
2655
|
+
)
|