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.
Files changed (261) hide show
  1. opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
  2. opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
  3. opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
  4. opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
  5. opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
  6. opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
  7. opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
  8. pynecore/__init__.py +6 -0
  9. pynecore/cli/__init__.py +2 -0
  10. pynecore/cli/app.py +238 -0
  11. pynecore/cli/commands/__init__.py +343 -0
  12. pynecore/cli/commands/benchmark.py +186 -0
  13. pynecore/cli/commands/compile.py +198 -0
  14. pynecore/cli/commands/data.py +857 -0
  15. pynecore/cli/commands/debug.py +63 -0
  16. pynecore/cli/commands/optimize.py +956 -0
  17. pynecore/cli/commands/plugin.py +242 -0
  18. pynecore/cli/commands/run.py +2006 -0
  19. pynecore/cli/pluggable.py +132 -0
  20. pynecore/cli/utils/__init__.py +0 -0
  21. pynecore/cli/utils/api_error_handler.py +168 -0
  22. pynecore/cli/utils/broker_picker.py +330 -0
  23. pynecore/cli/utils/error_hook.py +28 -0
  24. pynecore/cli/utils/keyreader.py +178 -0
  25. pynecore/cli/utils/provider_picker.py +19 -0
  26. pynecore/cli/utils/symbol_browser.py +1149 -0
  27. pynecore/core/__init__.py +0 -0
  28. pynecore/core/aggregator.py +257 -0
  29. pynecore/core/bar_magnifier.py +168 -0
  30. pynecore/core/broker/__init__.py +64 -0
  31. pynecore/core/broker/defaults.py +113 -0
  32. pynecore/core/broker/disappearance.py +927 -0
  33. pynecore/core/broker/emulator.py +345 -0
  34. pynecore/core/broker/exceptions.py +346 -0
  35. pynecore/core/broker/idempotency.py +401 -0
  36. pynecore/core/broker/intent_builder.py +334 -0
  37. pynecore/core/broker/journal.py +1785 -0
  38. pynecore/core/broker/models.py +1600 -0
  39. pynecore/core/broker/native_failsafe_manager.py +1436 -0
  40. pynecore/core/broker/one_way_emulator.py +1128 -0
  41. pynecore/core/broker/position.py +787 -0
  42. pynecore/core/broker/run_identity.py +126 -0
  43. pynecore/core/broker/software_entry_stop_engine.py +351 -0
  44. pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
  45. pynecore/core/broker/spot_inventory.py +1327 -0
  46. pynecore/core/broker/storage.py +2655 -0
  47. pynecore/core/broker/store_helpers.py +2161 -0
  48. pynecore/core/broker/sync_engine.py +16070 -0
  49. pynecore/core/broker/validation.py +382 -0
  50. pynecore/core/class_property.py +7 -0
  51. pynecore/core/config.py +392 -0
  52. pynecore/core/csv_file.py +547 -0
  53. pynecore/core/currency.py +262 -0
  54. pynecore/core/data_converter.py +1002 -0
  55. pynecore/core/datetime.py +296 -0
  56. pynecore/core/download_info.py +71 -0
  57. pynecore/core/download_runner.py +274 -0
  58. pynecore/core/htf_aggregator.py +181 -0
  59. pynecore/core/import_hook.py +358 -0
  60. pynecore/core/instance_state.py +494 -0
  61. pynecore/core/live_ltf_collector.py +442 -0
  62. pynecore/core/live_ltf_window.py +189 -0
  63. pynecore/core/live_runner.py +1347 -0
  64. pynecore/core/module_property.py +26 -0
  65. pynecore/core/ohlcv_file.py +1888 -0
  66. pynecore/core/overload.py +371 -0
  67. pynecore/core/pine_cast.py +113 -0
  68. pynecore/core/pine_export.py +95 -0
  69. pynecore/core/pine_method.py +244 -0
  70. pynecore/core/pine_range.py +86 -0
  71. pynecore/core/pine_udt.py +69 -0
  72. pynecore/core/plugin/__init__.py +394 -0
  73. pynecore/core/plugin/broker.py +781 -0
  74. pynecore/core/plugin/cli.py +96 -0
  75. pynecore/core/plugin/live_provider.py +208 -0
  76. pynecore/core/plugin/provider.py +331 -0
  77. pynecore/core/provider_string.py +148 -0
  78. pynecore/core/random.py +40 -0
  79. pynecore/core/resampler.py +686 -0
  80. pynecore/core/safe_convert.py +64 -0
  81. pynecore/core/script.py +1011 -0
  82. pynecore/core/script_runner.py +3202 -0
  83. pynecore/core/security.py +1749 -0
  84. pynecore/core/security_process.py +1253 -0
  85. pynecore/core/security_shm.py +456 -0
  86. pynecore/core/series.py +417 -0
  87. pynecore/core/strategy_stats.py +669 -0
  88. pynecore/core/symbol_map.py +134 -0
  89. pynecore/core/syminfo.py +505 -0
  90. pynecore/core/viz.py +591 -0
  91. pynecore/lib/__init__.py +1771 -0
  92. pynecore/lib/_fixnan.py +32 -0
  93. pynecore/lib/_math_stateful.py +202 -0
  94. pynecore/lib/_timeframe_change.py +101 -0
  95. pynecore/lib/adjustment.py +6 -0
  96. pynecore/lib/alert.py +39 -0
  97. pynecore/lib/alert.pyi +14 -0
  98. pynecore/lib/array.py +1051 -0
  99. pynecore/lib/barmerge.py +60 -0
  100. pynecore/lib/barstate.py +30 -0
  101. pynecore/lib/box.py +415 -0
  102. pynecore/lib/chart.py +128 -0
  103. pynecore/lib/color.py +152 -0
  104. pynecore/lib/color.pyi +50 -0
  105. pynecore/lib/currency.py +62 -0
  106. pynecore/lib/dayofweek.py +36 -0
  107. pynecore/lib/dayofweek.pyi +18 -0
  108. pynecore/lib/display.py +8 -0
  109. pynecore/lib/dividends.py +9 -0
  110. pynecore/lib/earnings.py +11 -0
  111. pynecore/lib/extend.py +6 -0
  112. pynecore/lib/font.py +5 -0
  113. pynecore/lib/footprint.py +79 -0
  114. pynecore/lib/format.py +11 -0
  115. pynecore/lib/hline.py +67 -0
  116. pynecore/lib/hline.pyi +24 -0
  117. pynecore/lib/label.py +409 -0
  118. pynecore/lib/line.py +433 -0
  119. pynecore/lib/linefill.py +93 -0
  120. pynecore/lib/location.py +11 -0
  121. pynecore/lib/log.py +362 -0
  122. pynecore/lib/map.py +150 -0
  123. pynecore/lib/math.py +385 -0
  124. pynecore/lib/matrix.py +708 -0
  125. pynecore/lib/order.py +8 -0
  126. pynecore/lib/pivotpointtype.py +8 -0
  127. pynecore/lib/plot.py +95 -0
  128. pynecore/lib/plot.pyi +33 -0
  129. pynecore/lib/polyline.py +91 -0
  130. pynecore/lib/position.py +15 -0
  131. pynecore/lib/request.py +281 -0
  132. pynecore/lib/runtime.py +5 -0
  133. pynecore/lib/scale.py +9 -0
  134. pynecore/lib/session.py +267 -0
  135. pynecore/lib/session.pyi +12 -0
  136. pynecore/lib/shape.py +18 -0
  137. pynecore/lib/size.py +12 -0
  138. pynecore/lib/splits.py +4 -0
  139. pynecore/lib/strategy/__init__.py +4778 -0
  140. pynecore/lib/strategy/closedtrades.py +347 -0
  141. pynecore/lib/strategy/closedtrades.pyi +53 -0
  142. pynecore/lib/strategy/commission.py +9 -0
  143. pynecore/lib/strategy/direction.py +9 -0
  144. pynecore/lib/strategy/oca.py +13 -0
  145. pynecore/lib/strategy/opentrades.py +281 -0
  146. pynecore/lib/strategy/opentrades.pyi +49 -0
  147. pynecore/lib/strategy/risk.py +109 -0
  148. pynecore/lib/string.py +649 -0
  149. pynecore/lib/syminfo.py +84 -0
  150. pynecore/lib/ta.py +2230 -0
  151. pynecore/lib/table.py +290 -0
  152. pynecore/lib/text.py +17 -0
  153. pynecore/lib/ticker.py +207 -0
  154. pynecore/lib/timeframe.py +293 -0
  155. pynecore/lib/volume_row.py +67 -0
  156. pynecore/lib/xloc.py +4 -0
  157. pynecore/lib/yloc.py +5 -0
  158. pynecore/providers/__init__.py +0 -0
  159. pynecore/providers/ccxt.py +664 -0
  160. pynecore/providers/replay.py +187 -0
  161. pynecore/pynesys/__init__.py +0 -0
  162. pynecore/pynesys/api.py +498 -0
  163. pynecore/pynesys/compiler.py +112 -0
  164. pynecore/standalone.py +99 -0
  165. pynecore/testing/__init__.py +1 -0
  166. pynecore/testing/broker_lab/__init__.py +41 -0
  167. pynecore/testing/broker_lab/__main__.py +5 -0
  168. pynecore/testing/broker_lab/cli.py +87 -0
  169. pynecore/testing/broker_lab/generate.py +47 -0
  170. pynecore/testing/broker_lab/model.py +84 -0
  171. pynecore/testing/broker_lab/reference.py +645 -0
  172. pynecore/testing/broker_lab/runner.py +372 -0
  173. pynecore/testing/broker_lab/scheduler.py +50 -0
  174. pynecore/testing/broker_lab/subprocess.py +73 -0
  175. pynecore/transformers/__init__.py +0 -0
  176. pynecore/transformers/builtin_shadow.py +136 -0
  177. pynecore/transformers/closure_arguments_transformer.py +428 -0
  178. pynecore/transformers/display_rewrite.py +140 -0
  179. pynecore/transformers/dynamic_default.py +147 -0
  180. pynecore/transformers/function_isolation.py +757 -0
  181. pynecore/transformers/import_lifter.py +61 -0
  182. pynecore/transformers/import_normalizer.py +328 -0
  183. pynecore/transformers/inline_series_hoist.py +178 -0
  184. pynecore/transformers/input_transformer.py +175 -0
  185. pynecore/transformers/lib_series.py +201 -0
  186. pynecore/transformers/locations.py +70 -0
  187. pynecore/transformers/module_properties.json +3387 -0
  188. pynecore/transformers/module_property.py +221 -0
  189. pynecore/transformers/ne_guard.py +70 -0
  190. pynecore/transformers/persistent.py +320 -0
  191. pynecore/transformers/persistent_series.py +76 -0
  192. pynecore/transformers/safe_convert_transformer.py +97 -0
  193. pynecore/transformers/safe_division_transformer.py +95 -0
  194. pynecore/transformers/script_requirements.py +308 -0
  195. pynecore/transformers/security.py +752 -0
  196. pynecore/transformers/security_instantiation.py +274 -0
  197. pynecore/transformers/series.py +275 -0
  198. pynecore/transformers/slot_layout.py +381 -0
  199. pynecore/transformers/type_checking_stripper.py +25 -0
  200. pynecore/transformers/unused_series_detector.py +267 -0
  201. pynecore/types/__init__.py +21 -0
  202. pynecore/types/alert.py +5 -0
  203. pynecore/types/barmerge.py +5 -0
  204. pynecore/types/base.py +39 -0
  205. pynecore/types/box.py +37 -0
  206. pynecore/types/chart.py +17 -0
  207. pynecore/types/color.py +107 -0
  208. pynecore/types/currency.py +5 -0
  209. pynecore/types/datetime.py +6 -0
  210. pynecore/types/display.py +5 -0
  211. pynecore/types/dividends.py +5 -0
  212. pynecore/types/earnings.py +5 -0
  213. pynecore/types/extend.py +5 -0
  214. pynecore/types/font.py +5 -0
  215. pynecore/types/footprint.py +41 -0
  216. pynecore/types/format.py +5 -0
  217. pynecore/types/hline.py +24 -0
  218. pynecore/types/ib_persistent.py +8 -0
  219. pynecore/types/ib_persistent.pyi +10 -0
  220. pynecore/types/label.py +35 -0
  221. pynecore/types/line.py +32 -0
  222. pynecore/types/linefill.py +13 -0
  223. pynecore/types/location.py +5 -0
  224. pynecore/types/matrix.py +999 -0
  225. pynecore/types/na.py +237 -0
  226. pynecore/types/na.pyi +83 -0
  227. pynecore/types/ohlcv.py +12 -0
  228. pynecore/types/order.py +5 -0
  229. pynecore/types/persistent.py +8 -0
  230. pynecore/types/persistent.pyi +13 -0
  231. pynecore/types/pine_types.py +11 -0
  232. pynecore/types/pine_types.pyi +15 -0
  233. pynecore/types/pivotpointtype.py +5 -0
  234. pynecore/types/plot.py +12 -0
  235. pynecore/types/plot_meta.py +60 -0
  236. pynecore/types/polyline.py +40 -0
  237. pynecore/types/position.py +5 -0
  238. pynecore/types/scale.py +5 -0
  239. pynecore/types/script_type.py +15 -0
  240. pynecore/types/series.py +23 -0
  241. pynecore/types/series.pyi +19 -0
  242. pynecore/types/session.py +35 -0
  243. pynecore/types/shape.py +5 -0
  244. pynecore/types/size.py +5 -0
  245. pynecore/types/source.py +33 -0
  246. pynecore/types/splits.py +5 -0
  247. pynecore/types/strategy.py +45 -0
  248. pynecore/types/table.py +87 -0
  249. pynecore/types/text.py +13 -0
  250. pynecore/types/type_checker.py +7 -0
  251. pynecore/types/type_checker.pyi +48 -0
  252. pynecore/types/volume_row.py +36 -0
  253. pynecore/types/weekdays.py +11 -0
  254. pynecore/types/xloc.py +5 -0
  255. pynecore/types/yloc.py +5 -0
  256. pynecore/utils/__init__.py +0 -0
  257. pynecore/utils/file_utils.py +50 -0
  258. pynecore/utils/rich/__init__.py +0 -0
  259. pynecore/utils/rich/date_column.py +25 -0
  260. pynecore/utils/sequence_view.py +92 -0
  261. 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
+ )