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,927 @@
1
+ """Generic bot-owned-order disappearance tracking for broker plugins.
2
+
3
+ Detecting bot-owned orders that vanish behind the engine's back (manual
4
+ close on the broker UI, broker-side liquidation, silent cancel) is a
5
+ plugin responsibility, but its core state machine is venue-agnostic and
6
+ was implemented twice (Capital.com and cTrader). This module extracts
7
+ that core:
8
+
9
+ - **Persisted stamp / clear / grace state machine.** A row whose broker
10
+ counterpart has vanished from every tracked namespace gets a
11
+ ``missing_pending_since`` stamp in its ``extras``; the stamp is cleared
12
+ the moment any tracked ref reappears. Only a stamp older than the grace
13
+ window triggers any irreversible action — a fill in flight can flicker
14
+ out of every snapshot for one poll. Stamp and clear both re-read the
15
+ live row and touch *only* the ``missing_pending_since`` key, so a
16
+ breadcrumb written concurrently by another broker thread is never
17
+ clobbered.
18
+ - **Typed ``(namespace, ref)`` keys.** A venue stores bot orders in
19
+ several resource namespaces (working orders, open positions,
20
+ position-attached brackets); refs are tracked per namespace so an
21
+ order-id can never collide with a position-id.
22
+ - **Per-namespace snapshot completeness.** A namespace whose fetch failed
23
+ this pass is reported as ``None``: rows tracked in it are neither
24
+ stamped nor cleared — an incomplete snapshot must never look like a
25
+ complete absence.
26
+ - **Declarative grace-expiry classification.** At grace expiry the venue
27
+ hook ``confirm_missing`` re-verifies the disappearance (e.g. against
28
+ the deal history) and returns a :class:`MissingConfirmation` — it does
29
+ NOT mutate. The tracker applies the outcome in ONE serialized store
30
+ transaction: discovered fill slice + terminal state + sibling
31
+ retirement together, so a partial-fill-then-cancel can never lose its
32
+ fill slice, and a crash cannot leave a half-applied resolution. Every
33
+ post-apply artefact (the cancelled event, the policy hook, the
34
+ execution registration) is built from the row re-read *after* the fill
35
+ slice was booked, so none of them can carry stale pre-fill quantities.
36
+ - **Stamp-version guard.** The row can come back (or be re-stamped)
37
+ while the async confirmation runs; the apply transaction re-checks the
38
+ original ``missing_pending_since`` value and drops a stale outcome.
39
+ - **Fail-closed on unpriced fills.** A discovered fill slice is only
40
+ booked when it carries a strictly positive price and a strictly
41
+ positive persisted quantity delta. An unpriced fill would be dropped by
42
+ the engine's ``record_fill`` yet still advance the store's
43
+ ``filled_qty`` and seed the plugin's dedup channel — so the tracker
44
+ keeps the stamp and defers instead of concluding on unpriced evidence.
45
+ - **Dual signal.** A confirmed unexpected cancel is booked as a terminal
46
+ close AND yielded as a synthesised ``cancelled`` :class:`OrderEvent`
47
+ (the engine's router cleans its tracking — essential under the
48
+ non-halting policies), while the configured ``on_unexpected_cancel``
49
+ policy separately decides the operational reaction. ``stop`` /
50
+ ``stop_and_cancel`` QUARANTINE through the ``request_quarantine`` hook:
51
+ trading stops (the engine blocks new / exposure-increasing dispatch)
52
+ but the process — and this tracker's ingestion loop — stays alive;
53
+ raising here instead would tear down the very event stream the
54
+ quarantine invariant requires to keep running. Only the explicit
55
+ ``halt`` policy (or a quarantining policy with no hook wired — the
56
+ fail-safe fallback) arms the process-exiting halt. Persistence and
57
+ policy application run BEFORE the event is yielded, so neither depends
58
+ on the consumer pulling another element from the generator; a pending
59
+ halt survives an abandoned generator and re-raises on the next
60
+ :meth:`DisappearanceTracker.observe` call. The halt is delivered
61
+ exactly once — each raise site consumes it.
62
+ """
63
+ import logging
64
+ import math
65
+ from collections.abc import (
66
+ AsyncIterator,
67
+ Awaitable,
68
+ Callable,
69
+ Iterable,
70
+ Mapping,
71
+ Set,
72
+ )
73
+ from dataclasses import dataclass
74
+ from enum import StrEnum
75
+ from typing import TYPE_CHECKING, Any
76
+
77
+ from pynecore.core.broker.exceptions import UnexpectedCancelError
78
+ from pynecore.core.broker.journal import (
79
+ DispatchJournal,
80
+ ReconcileOutcome,
81
+ ReconcileReason,
82
+ )
83
+ from pynecore.core.broker.models import (
84
+ ExchangeOrder,
85
+ LegType,
86
+ OrderEvent,
87
+ OrderStatus,
88
+ OrderType,
89
+ )
90
+
91
+ if TYPE_CHECKING:
92
+ from pynecore.core.broker.storage import OrderRow, RunContext
93
+
94
+ __all__ = [
95
+ 'MISSING_PENDING_EXTRA',
96
+ 'UNEXPECTED_CANCEL_POLICIES',
97
+ 'DisappearanceTracker',
98
+ 'MissingConfirmation',
99
+ 'MissingResolution',
100
+ 'resolve_unexpected_cancel_policy',
101
+ ]
102
+
103
+ logger = logging.getLogger(__name__)
104
+
105
+ #: The persisted observation breadcrumb key in ``OrderRow.extras``. The
106
+ #: same key both reference plugins already use, so a refactor onto the
107
+ #: tracker inherits stamps persisted by earlier plugin versions.
108
+ MISSING_PENDING_EXTRA = 'missing_pending_since'
109
+
110
+ #: Valid ``on_unexpected_cancel`` policies (see ``BrokerDefaults``).
111
+ UNEXPECTED_CANCEL_POLICIES = (
112
+ 'stop', 'stop_and_cancel', 're_place', 'ignore', 'halt',
113
+ )
114
+
115
+ #: Float comparison slack for fill quantities, matching the reconcile
116
+ #: paths in the reference plugins.
117
+ _QTY_EPS = 1e-9
118
+
119
+
120
+ def resolve_unexpected_cancel_policy(
121
+ policy: str,
122
+ *,
123
+ reason: str,
124
+ context: dict[str, Any],
125
+ request_quarantine: Callable[[str, dict[str, Any]], None] | None,
126
+ log_event: Callable[..., None] | None,
127
+ client_order_id: str | None,
128
+ exchange_order_id: str | None,
129
+ ) -> UnexpectedCancelError | None:
130
+ """Resolve the ``on_unexpected_cancel`` policy for a confirmed
131
+ unexpected cancel — the mapping shared by the grace-window tracker path
132
+ (:meth:`DisappearanceTracker._apply_policy`) and the sync engine's
133
+ WS-push handler
134
+ (:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._apply_unexpected_cancel_policy`).
135
+
136
+ Applies the ``ignore`` / ``re_place`` audit-only logging and the
137
+ ``stop`` / ``stop_and_cancel`` quarantine latch, and RETURNS the halt
138
+ the caller must arm its own way (the tracker parks it on
139
+ :attr:`pending_halt`; the engine records + raises it) — or ``None``
140
+ when the outcome needed no halt (ignored, re-placed, or quarantined).
141
+ The fail-closed rule lives here: a ``stop`` / ``stop_and_cancel``
142
+ whose ``request_quarantine`` sink is missing or raises falls back to
143
+ the halt, never to continued trading. The caller owns the
144
+ ``stop_and_cancel`` sibling sweep — it is async and shaped differently
145
+ per path.
146
+
147
+ :param request_quarantine: Quarantine latch sink; ``None`` (an unwired
148
+ sink under a quarantining policy) triggers the fail-closed halt.
149
+ :param log_event: Audit sink (``RunContext.log_event``); ``None`` skips
150
+ audit persistence (a store-less engine on the push path).
151
+ """
152
+ def _log(kind: str) -> None:
153
+ if log_event is not None:
154
+ log_event(
155
+ kind,
156
+ client_order_id=client_order_id,
157
+ exchange_order_id=exchange_order_id,
158
+ )
159
+
160
+ if policy == 'ignore':
161
+ _log('unexpected_cancel_ignored')
162
+ return None
163
+ if policy == 're_place':
164
+ _log('unexpected_cancel_re_place')
165
+ return None
166
+ quarantined = False
167
+ if (policy in ('stop', 'stop_and_cancel')
168
+ and request_quarantine is not None):
169
+ # noinspection PyBroadException
170
+ try:
171
+ request_quarantine(reason, context)
172
+ except Exception:
173
+ logger.exception(
174
+ "request_quarantine hook failed for %r; falling back "
175
+ "to the process-exiting halt", client_order_id,
176
+ )
177
+ else:
178
+ quarantined = True
179
+ _log('unexpected_cancel_quarantine')
180
+ if not quarantined:
181
+ # 'halt', or a quarantining policy whose hook is missing / raised:
182
+ # arm the process-exiting manual-intervention signal.
183
+ return UnexpectedCancelError(reason, context=context)
184
+ return None
185
+
186
+
187
+ class MissingResolution(StrEnum):
188
+ """Grace-expiry classification returned by the ``confirm_missing`` hook.
189
+
190
+ - :data:`STILL_PRESENT` — the row's broker counterpart is verifiably
191
+ back (or the earlier absence was a snapshot artifact): clear the
192
+ stamp, no further action.
193
+ - :data:`INCONCLUSIVE` — the verification could not complete (e.g.
194
+ history transport down, pagination hole): keep the stamp and wait;
195
+ never conclude a cancel from missing evidence.
196
+ - :data:`FILLED` — the ref vanished because it (partially or fully)
197
+ filled: the stamp premise is false; any discovered fill slice is
198
+ applied and the row stays live for the venue's normal promotion
199
+ path.
200
+ - :data:`CLOSED` — the row filled and its position was then closed
201
+ (native TP/SL fired, external close): retire the row (and its
202
+ position siblings) as a natural close — never a synthetic cancel,
203
+ and no fill event for exposure that no longer exists.
204
+ - :data:`CANCELLED` — verified external cancel: terminal-close the
205
+ row, emit the synthesised ``cancelled`` event and apply the
206
+ ``on_unexpected_cancel`` policy.
207
+ """
208
+ STILL_PRESENT = 'still_present'
209
+ INCONCLUSIVE = 'inconclusive'
210
+ FILLED = 'filled'
211
+ CLOSED = 'closed'
212
+ CANCELLED = 'cancelled'
213
+
214
+
215
+ @dataclass(frozen=True, slots=True)
216
+ class MissingConfirmation:
217
+ """Declarative result of a grace-expiry re-verification.
218
+
219
+ The ``confirm_missing`` hook builds this from venue evidence (deal
220
+ history, activity log, order-status probe) and mutates NOTHING — the
221
+ tracker applies the whole outcome in one store transaction so it is
222
+ either fully booked or fully dropped.
223
+
224
+ Any terminal resolution may carry a discovered fill slice: a working
225
+ order that partially filled and was then externally cancelled
226
+ resolves as :data:`MissingResolution.CANCELLED` *with* fill data, and
227
+ the tracker books the slice in the same transaction as the terminal
228
+ close. A fill slice is only booked when ``fill_price`` is strictly
229
+ positive; an unpriced slice makes the tracker defer (keep the stamp)
230
+ rather than book exposure the engine would silently drop.
231
+
232
+ :ivar resolution: The classification — see :class:`MissingResolution`.
233
+ A plain string equal to one of the enum values is coerced.
234
+ :ivar cumulative_filled_qty: The order's proven cumulative filled
235
+ quantity, when the verification discovered fill progress. The
236
+ tracker clamps it into ``[row.filled_qty, row.qty]`` (monotonic,
237
+ never overstating the order's own size) and books the delta.
238
+ :ivar fill_price: Volume-weighted price of the discovered executions
239
+ (the price the delta is booked at). Must be strictly positive for
240
+ the slice to be booked.
241
+ :ivar fill_fee: Summed commission of the discovered executions.
242
+ :ivar execution_ids: Venue execution/deal ids backing the evidence.
243
+ Passed to the ``register_executions`` hook after a fill slice was
244
+ actually booked or the row was retired on a terminal resolution,
245
+ so the plugin can seed its duplicate-fill channel; a lone id is
246
+ also stamped as the fill event's ``fill_id``.
247
+ :ivar position_ref: Venue position id the fill belongs to. Selects
248
+ the ``working_promoted_position`` audit reason and scopes the
249
+ sibling retirement on :data:`MissingResolution.CLOSED`.
250
+ :ivar extras_patch: Plugin extras merged into the row alongside a
251
+ discovered fill (e.g. ``{'position_id': ...}``).
252
+ :ivar executed_ts: Broker-reported execution time (unix seconds) for
253
+ the fill event; falls back to the observation time.
254
+ """
255
+ resolution: MissingResolution
256
+ cumulative_filled_qty: float | None = None
257
+ fill_price: float | None = None
258
+ fill_fee: float = 0.0
259
+ execution_ids: tuple[str, ...] = ()
260
+ position_ref: str | None = None
261
+ extras_patch: Mapping[str, Any] | None = None
262
+ executed_ts: float | None = None
263
+
264
+ def __post_init__(self) -> None:
265
+ # Coerce a plain string (or reject an invalid value at construction
266
+ # time) so the apply path can rely on identity checks against the
267
+ # enum members and never fall through to the CANCELLED mutation.
268
+ if not isinstance(self.resolution, MissingResolution):
269
+ object.__setattr__(
270
+ self, 'resolution', MissingResolution(self.resolution),
271
+ )
272
+
273
+
274
+ class DisappearanceTracker:
275
+ """Venue-agnostic disappearance state machine over the BrokerStore.
276
+
277
+ One instance per plugin. The plugin's snapshot loop calls
278
+ :meth:`observe` once per pass with the per-namespace present-ref
279
+ sets; the tracker owns stamping, clearing, grace accounting, the
280
+ grace-expiry confirmation protocol and the dual-signal delivery. All
281
+ venue knowledge enters through the constructor hooks.
282
+
283
+ :param store_ctx: The plugin's open :class:`RunContext`.
284
+ :param grace_s: Seconds a stamp must age before ``confirm_missing``
285
+ runs. Venue-tuned (``max(5, 5 × poll cadence)`` on the reference
286
+ plugins).
287
+ :param policy: The configured ``on_unexpected_cancel`` policy —
288
+ one of :data:`UNEXPECTED_CANCEL_POLICIES`.
289
+ :param tracked_refs: Maps a live row to its ``{(namespace, ref)}``
290
+ set. An empty set exempts the row from tracking (e.g. bracket
291
+ legs with no broker id of their own).
292
+ :param confirm_missing: Async grace-expiry verifier — returns a
293
+ :class:`MissingConfirmation`, mutates nothing.
294
+ :param is_exempt: Optional extra exemption (e.g. rows already flagged
295
+ as naturally closed). Checked on every pass, both phases.
296
+ :param cancel_siblings: Async best-effort cancel sweep over the
297
+ origin row's sibling orders; required by (and only used for) the
298
+ ``stop_and_cancel`` policy. Best-effort — a raising sweep is
299
+ logged and does not swallow the armed quarantine / halt.
300
+ :param request_quarantine: Sink that latches the engine's quarantine
301
+ state (``(reason, context)``); the runner wires it to
302
+ ``OrderSyncEngine.record_quarantine``. Used by the ``stop`` and
303
+ ``stop_and_cancel`` policies. When it is missing (or raises),
304
+ those policies fall back to arming the process-exiting halt —
305
+ fail-safe, never fail-open into continued trading.
306
+ :param sibling_coids: Maps a :data:`MissingResolution.CLOSED` row to
307
+ the client-order-ids of live sibling rows sharing its position
308
+ (a netting account merges pyramid entries onto one position id);
309
+ they are retired in the same transaction.
310
+ :param register_executions: Called with the confirmation's
311
+ ``execution_ids`` after a fill slice was actually booked or the
312
+ row was retired on a terminal resolution, so the plugin can seed
313
+ its duplicate-fill channel before any replayed push event. A
314
+ raising hook is logged, not propagated.
315
+ :param cancelled_event_factory: Overrides the synthesised
316
+ ``cancelled`` event construction (venues that key the event on
317
+ something other than ``row.exchange_order_id``). The default
318
+ builder is entry-shaped (``LegType.ENTRY``, ``reduce_only=False``,
319
+ ``OrderType.MARKET``); a non-entry row MUST supply a custom factory.
320
+ :param fill_event_factory: Overrides the recovered-fill event
321
+ construction. Receives ``(row, confirmation, cumulative,
322
+ fill_qty, now_ts)``. The default builder is entry-shaped as
323
+ above; a non-entry row MUST supply a custom factory.
324
+ """
325
+
326
+ def __init__(
327
+ self,
328
+ store_ctx: 'RunContext',
329
+ *,
330
+ grace_s: float,
331
+ policy: str,
332
+ tracked_refs: Callable[['OrderRow'], Set[tuple[str, str]]],
333
+ confirm_missing: Callable[['OrderRow'], Awaitable[MissingConfirmation]],
334
+ is_exempt: Callable[['OrderRow'], bool] | None = None,
335
+ cancel_siblings: Callable[['OrderRow'], Awaitable[None]] | None = None,
336
+ request_quarantine: Callable[[str, dict[str, Any]], None] | None = None,
337
+ sibling_coids: Callable[
338
+ ['OrderRow', MissingConfirmation], Iterable[str]] | None = None,
339
+ register_executions: Callable[
340
+ ['OrderRow', tuple[str, ...]], None] | None = None,
341
+ cancelled_event_factory: Callable[
342
+ ['OrderRow', float], OrderEvent] | None = None,
343
+ fill_event_factory: Callable[
344
+ ['OrderRow', MissingConfirmation, float, float, float],
345
+ OrderEvent] | None = None,
346
+ ) -> None:
347
+ if policy not in UNEXPECTED_CANCEL_POLICIES:
348
+ raise ValueError(
349
+ f"DisappearanceTracker: unknown policy {policy!r}; "
350
+ f"expected one of {UNEXPECTED_CANCEL_POLICIES}"
351
+ )
352
+ if policy == 'stop_and_cancel' and cancel_siblings is None:
353
+ raise ValueError(
354
+ "DisappearanceTracker: policy 'stop_and_cancel' requires "
355
+ "a cancel_siblings hook"
356
+ )
357
+ self._store = store_ctx
358
+ self._grace_s = grace_s
359
+ self._policy = policy
360
+ self._tracked_refs = tracked_refs
361
+ self._confirm_missing = confirm_missing
362
+ self._is_exempt = is_exempt
363
+ self._cancel_siblings = cancel_siblings
364
+ self._request_quarantine = request_quarantine
365
+ self._sibling_coids = sibling_coids
366
+ self._register_executions = register_executions
367
+ self._cancelled_event_factory = cancelled_event_factory
368
+ self._fill_event_factory = fill_event_factory
369
+ #: Halt decided by a halting policy but not yet delivered to the
370
+ #: consumer. Survives an abandoned generator: re-raised at the top
371
+ #: of the next :meth:`observe` call. Consumed (cleared) by whichever
372
+ #: raise site delivers it, so it fires exactly once.
373
+ self._pending_halt: UnexpectedCancelError | None = None
374
+ #: ``(coid, event_kind)`` pairs already warned about a deferred
375
+ #: grace re-check, so a sustained anomaly logs once per row per
376
+ #: reason (an inconclusive re-check must not mute a later unpriced
377
+ #: fill on the same row), not once per cadence.
378
+ self._warned_deferred: set[tuple[str, str]] = set()
379
+
380
+ @property
381
+ def pending_halt(self) -> UnexpectedCancelError | None:
382
+ """The undelivered halt decided by a halting policy, if any.
383
+
384
+ Observational only — delivery happens by the :class:`UnexpectedCancelError`
385
+ raised from :meth:`observe`, which consumes it. A plugin that both
386
+ consumes the generator and reads this property must not act on the
387
+ property independently, or it would double-handle the same halt.
388
+ """
389
+ return self._pending_halt
390
+
391
+ def _take_pending_halt(self) -> UnexpectedCancelError | None:
392
+ """Consume the pending halt so it is delivered exactly once."""
393
+ halt = self._pending_halt
394
+ self._pending_halt = None
395
+ return halt
396
+
397
+ async def observe(
398
+ self,
399
+ present: Mapping[str, Set[str] | None],
400
+ now_ts: float,
401
+ ) -> AsyncIterator[OrderEvent]:
402
+ """Run one observation pass and yield the recovered events.
403
+
404
+ Phase 1 stamps / clears every tracked live row against
405
+ ``present``; phase 2 runs the grace-expiry confirmation protocol
406
+ on rows whose stamp has aged past the grace window.
407
+
408
+ :param present: Per-namespace sets of the refs visible in this
409
+ pass's snapshot. ``None`` (or a missing key) marks a
410
+ namespace whose fetch FAILED — rows tracked in it are
411
+ neither stamped nor cleared this pass.
412
+ :param now_ts: The observation timestamp (unix seconds); also
413
+ the value stamped into ``missing_pending_since``.
414
+ :raises UnexpectedCancelError: After the ``cancelled`` event of a
415
+ row whose policy is halting — or immediately, when a halt
416
+ decided on an earlier (abandoned) pass is still undelivered.
417
+ """
418
+ halt = self._take_pending_halt()
419
+ if halt is not None:
420
+ raise halt
421
+
422
+ self.observe_presence(present, now_ts)
423
+
424
+ for row in list(self._store.iter_live_orders()):
425
+ if self._is_exempt is not None and self._is_exempt(row):
426
+ continue
427
+ extras = row.extras or {}
428
+ since = extras.get(MISSING_PENDING_EXTRA)
429
+ if not isinstance(since, (int, float)):
430
+ continue
431
+ since_ts = float(since)
432
+ if (now_ts - since_ts) < self._grace_s:
433
+ continue
434
+ confirmation = await self._confirm_missing(row)
435
+ events, applied, register_ids, applied_row = self._apply_confirmation(
436
+ row, since_ts, confirmation, now_ts,
437
+ )
438
+ # The post-commit hooks must see the row as it stands after the
439
+ # fill slice was booked — a stale pre-fill row would let a sweep
440
+ # miss the recovered quantity / promotion metadata.
441
+ hook_row = applied_row if applied_row is not None else row
442
+ if applied and register_ids and self._register_executions is not None:
443
+ # noinspection PyBroadException
444
+ try:
445
+ self._register_executions(hook_row, register_ids)
446
+ except Exception:
447
+ # A committed terminal row must never be stranded
448
+ # without its event / halt by a dedup-seeding failure.
449
+ logger.exception(
450
+ "register_executions hook failed for %r",
451
+ row.client_order_id,
452
+ )
453
+ if applied and confirmation.resolution is MissingResolution.CANCELLED:
454
+ await self._apply_policy(hook_row)
455
+ for event in events:
456
+ yield event
457
+ halt = self._take_pending_halt()
458
+ if halt is not None:
459
+ raise halt
460
+
461
+ # --- Phase 1: stamp / clear against the present-sets --------------------
462
+
463
+ def observe_presence(
464
+ self,
465
+ present: Mapping[str, Set[str] | None],
466
+ now_ts: float,
467
+ ) -> None:
468
+ """Run phase 1 only: stamp / clear every tracked live row.
469
+
470
+ For venues whose snapshot-reconcile pass owns the presence diff
471
+ (stamping from inside the same walk that books fills) while a
472
+ separate later pass drives the grace protocol via
473
+ :meth:`observe`. :meth:`observe` runs this itself, so calling
474
+ both against the same snapshot is safe — stamp and clear are
475
+ idempotent per pass.
476
+ """
477
+ live_coids: set[str] = set()
478
+ for row in list(self._store.iter_live_orders()):
479
+ live_coids.add(row.client_order_id)
480
+ if self._is_exempt is not None and self._is_exempt(row):
481
+ continue
482
+ self._observe_presence(row, present, now_ts)
483
+
484
+ # Drop throttle keys for rows that went terminal via another path
485
+ # (a PUSH event) and left the live set without reaching a resolve —
486
+ # else the in-memory set grows unbounded on a long-running instance.
487
+ if self._warned_deferred:
488
+ self._warned_deferred = {
489
+ k for k in self._warned_deferred if k[0] in live_coids
490
+ }
491
+
492
+ def _observe_presence(
493
+ self,
494
+ row: 'OrderRow',
495
+ present: Mapping[str, Set[str] | None],
496
+ now_ts: float,
497
+ ) -> None:
498
+ refs = self._tracked_refs(row)
499
+ if not refs:
500
+ return
501
+ visible = False
502
+ all_fetched = True
503
+ for namespace, ref in refs:
504
+ ns_refs = present.get(namespace)
505
+ if ns_refs is None:
506
+ all_fetched = False
507
+ continue
508
+ if ref in ns_refs:
509
+ visible = True
510
+ break
511
+ extras = row.extras or {}
512
+ if visible:
513
+ if MISSING_PENDING_EXTRA in extras:
514
+ self._clear_stamp(row.client_order_id)
515
+ return
516
+ if not all_fetched:
517
+ # Incomplete snapshot: absence is unproven — neither stamp
518
+ # nor clear.
519
+ return
520
+ if MISSING_PENDING_EXTRA not in extras:
521
+ self._stamp(row.client_order_id, now_ts)
522
+
523
+ def _stamp(self, coid: str, now_ts: float) -> None:
524
+ """Stamp ``missing_pending_since`` atomically, preserving extras.
525
+
526
+ Re-reads the live row and rewrites only the one key so a breadcrumb
527
+ another broker thread wrote between the phase-1 snapshot and here is
528
+ not clobbered; a stamp added concurrently is left untouched.
529
+ """
530
+ with self._store.transaction():
531
+ fresh = self._store.get_order(coid)
532
+ if fresh is None or fresh.closed_ts_ms is not None:
533
+ return
534
+ extras = dict(fresh.extras or {})
535
+ if MISSING_PENDING_EXTRA in extras:
536
+ return
537
+ extras[MISSING_PENDING_EXTRA] = now_ts
538
+ self._store.upsert_order(coid, extras=extras)
539
+
540
+ def _clear_stamp(self, coid: str) -> None:
541
+ """Remove ``missing_pending_since`` atomically, preserving extras."""
542
+ with self._store.transaction():
543
+ fresh = self._store.get_order(coid)
544
+ if fresh is not None:
545
+ extras = fresh.extras or {}
546
+ if MISSING_PENDING_EXTRA in extras:
547
+ merged = {k: v for k, v in extras.items()
548
+ if k != MISSING_PENDING_EXTRA}
549
+ self._store.upsert_order(coid, extras=merged)
550
+ self._forget_deferred(coid)
551
+
552
+ # --- Phase 2: grace-expiry confirmation apply ----------------------------
553
+
554
+ def _apply_confirmation(
555
+ self,
556
+ row: 'OrderRow',
557
+ since: float,
558
+ confirmation: MissingConfirmation,
559
+ now_ts: float,
560
+ ) -> tuple[list[OrderEvent], bool, tuple[str, ...], 'OrderRow | None']:
561
+ """Apply one confirmation atomically under the stamp-version guard.
562
+
563
+ Returns ``(events, applied, register_ids, applied_row)``. ``applied``
564
+ is ``False`` when the guard dropped a stale outcome (the row came
565
+ back, was re-stamped or reached a terminal state while the
566
+ confirmation ran) or when a fill slice was deferred for lack of a
567
+ price. ``register_ids`` is non-empty only when a fill slice was
568
+ actually booked OR the row reached a terminal resolution (CLOSED /
569
+ CANCELLED) in the same transaction — the retirement was concluded
570
+ FROM that execution evidence, so a replayed push copy of it must
571
+ already be suppressed. A deferred or dropped outcome never seeds
572
+ the dedup channel: the evidence may still need to book later.
573
+ ``applied_row`` is the row re-read *after* the fill slice was
574
+ booked (``None`` on a dropped or no-op outcome); the caller feeds
575
+ it to the post-commit hooks so a sweep never sees a stale pre-fill
576
+ quantity.
577
+ """
578
+ resolution = confirmation.resolution
579
+ events: list[OrderEvent] = []
580
+ with self._store.transaction():
581
+ fresh = self._store.get_order(row.client_order_id)
582
+ if fresh is None or fresh.closed_ts_ms is not None:
583
+ return [], False, (), None
584
+ stamp = (fresh.extras or {}).get(MISSING_PENDING_EXTRA)
585
+ if stamp != since:
586
+ logger.info(
587
+ "disappearance confirmation for %r dropped: stamp "
588
+ "changed during verification (%r -> %r)",
589
+ row.client_order_id, since, stamp,
590
+ )
591
+ return [], False, (), None
592
+
593
+ if resolution is MissingResolution.INCONCLUSIVE:
594
+ self._warn_deferred(
595
+ fresh, 'missing_pending_recheck_inconclusive',
596
+ "grace-expired row %r left un-retired: disappearance "
597
+ "re-check inconclusive — deferring rather than "
598
+ "concluding a false cancel",
599
+ )
600
+ return [], True, (), None
601
+
602
+ if resolution is MissingResolution.STILL_PRESENT:
603
+ self._clear_stamp(fresh.client_order_id)
604
+ return [], True, (), None
605
+
606
+ # Discovered fill slice (pure computation, no writes yet).
607
+ cumulative = self._clamped_cumulative(fresh, confirmation)
608
+ new_qty = 0.0 if cumulative is None else cumulative - fresh.filled_qty
609
+ has_new_fill = new_qty > _QTY_EPS
610
+ if has_new_fill and not (
611
+ confirmation.fill_price is not None
612
+ and math.isfinite(confirmation.fill_price)
613
+ and confirmation.fill_price > 0.0):
614
+ # Fail-closed: a fill we cannot price (missing, non-finite,
615
+ # or non-positive) would be dropped by the engine's
616
+ # record_fill yet still advance the store and seed dedup.
617
+ # Keep the stamp and defer.
618
+ self._warn_deferred(
619
+ fresh, 'missing_pending_fill_unpriced',
620
+ "grace-expired row %r reported fill progress without a "
621
+ "usable price — deferring rather than booking an "
622
+ "unpriced fill",
623
+ )
624
+ return [], False, (), None
625
+
626
+ register_ids: tuple[str, ...] = ()
627
+ fill_event: OrderEvent | None = None
628
+ updated = fresh
629
+ if has_new_fill and cumulative is not None:
630
+ self._book_fill(fresh, confirmation, cumulative)
631
+ updated = self._store.get_order(fresh.client_order_id) or fresh
632
+ fill_event = self._build_fill_event(
633
+ updated, confirmation, cumulative, new_qty, now_ts,
634
+ )
635
+ register_ids = confirmation.execution_ids
636
+ elif confirmation.position_ref is not None and cumulative is not None:
637
+ # Working->position promotion without fresh quantity (the
638
+ # fill was already booked): flip state + extras, emit and
639
+ # register nothing.
640
+ self._book_fill(fresh, confirmation, cumulative)
641
+ updated = self._store.get_order(fresh.client_order_id) or fresh
642
+ elif confirmation.extras_patch:
643
+ # Metadata-only patch: merge extras WITHOUT manufacturing a
644
+ # kind='filled' journal outcome for a zero-delta write.
645
+ merged = dict(fresh.extras or {})
646
+ merged.update(confirmation.extras_patch)
647
+ self._store.upsert_order(fresh.client_order_id, extras=merged)
648
+ updated = self._store.get_order(fresh.client_order_id) or fresh
649
+
650
+ if resolution is MissingResolution.FILLED:
651
+ # The stamp premise is false — the ref vanished because it
652
+ # filled. The row stays live; the venue's normal snapshot
653
+ # promotion path owns it from here.
654
+ self._clear_stamp(updated.client_order_id)
655
+ if fill_event is not None:
656
+ events.append(fill_event)
657
+ return events, True, register_ids, updated
658
+
659
+ if resolution is MissingResolution.CLOSED:
660
+ # Filled-then-closed: the exposure no longer exists, so no
661
+ # fill event is emitted for it (the engine's position
662
+ # reconcile owns the size side) — but any discovered fill
663
+ # progress was persisted above for bookkeeping. The
664
+ # retirement is concluded FROM the confirmation's execution
665
+ # evidence, so its ids are registered even without a fresh
666
+ # booked slice — a replayed push copy of an already-counted
667
+ # deal must not resurface as a new fill after the retire.
668
+ register_ids = confirmation.execution_ids
669
+ DispatchJournal(self._store).apply_reconcile_outcome(
670
+ updated.client_order_id,
671
+ ReconcileOutcome(
672
+ kind='terminal_close',
673
+ reason='bracket_natural_close_followup',
674
+ new_state='closed',
675
+ audit_event='reconcile_filled_then_closed_retired',
676
+ close_row=True,
677
+ audit_payload={
678
+ 'position_ref': confirmation.position_ref,
679
+ 'missing_since': since,
680
+ 'execution_ids': list(confirmation.execution_ids),
681
+ },
682
+ exchange_order_id=(confirmation.position_ref
683
+ or updated.exchange_order_id),
684
+ ),
685
+ )
686
+ if self._sibling_coids is not None:
687
+ for sibling in self._sibling_coids(updated, confirmation):
688
+ self._store.close_order(sibling)
689
+ self._forget_deferred(updated.client_order_id)
690
+ return [], True, register_ids, updated
691
+
692
+ if resolution is MissingResolution.CANCELLED:
693
+ # Terminal like CLOSED above: register the backing ids so
694
+ # a replayed copy of the evidence cannot re-book after the
695
+ # retire.
696
+ register_ids = confirmation.execution_ids
697
+ DispatchJournal(self._store).apply_reconcile_outcome(
698
+ updated.client_order_id,
699
+ ReconcileOutcome(
700
+ kind='terminal_close',
701
+ reason='missing_pending_grace_expired',
702
+ new_state='rejected',
703
+ audit_event='unexpected_cancel',
704
+ close_row=True,
705
+ audit_payload={'missing_since': since,
706
+ 'grace': self._grace_s},
707
+ exchange_order_id=updated.exchange_order_id,
708
+ ),
709
+ )
710
+ if fill_event is not None:
711
+ events.append(fill_event)
712
+ events.append(self._build_cancelled_event(updated, now_ts))
713
+ self._forget_deferred(updated.client_order_id)
714
+ return events, True, register_ids, updated
715
+
716
+ raise AssertionError(f"unhandled resolution {resolution!r}")
717
+
718
+ @staticmethod
719
+ def _clamped_cumulative(
720
+ fresh: 'OrderRow', confirmation: MissingConfirmation,
721
+ ) -> float | None:
722
+ """Clamp the confirmed cumulative into ``[fresh.filled_qty, fresh.qty]``.
723
+
724
+ Monotonic (never regresses below what is already booked) and never
725
+ overstates the order's own size. ``None`` when the confirmation
726
+ carried no fill quantity.
727
+ """
728
+ if confirmation.cumulative_filled_qty is None:
729
+ return None
730
+ return min(
731
+ fresh.qty,
732
+ max(fresh.filled_qty, confirmation.cumulative_filled_qty),
733
+ )
734
+
735
+ def _book_fill(
736
+ self,
737
+ fresh: 'OrderRow',
738
+ confirmation: MissingConfirmation,
739
+ cumulative: float,
740
+ ) -> None:
741
+ """Persist the discovered fill slice inside the caller's transaction."""
742
+ reason: ReconcileReason = (
743
+ 'working_promoted_position'
744
+ if confirmation.position_ref is not None
745
+ else 'partial_fill_progress'
746
+ )
747
+ DispatchJournal(self._store).apply_reconcile_outcome(
748
+ fresh.client_order_id,
749
+ ReconcileOutcome(
750
+ kind='filled',
751
+ reason=reason,
752
+ new_state='confirmed',
753
+ audit_event='reconcile_missing_fill_recovered',
754
+ filled_qty=cumulative,
755
+ extras_patch=confirmation.extras_patch,
756
+ audit_payload={
757
+ 'cumulative': cumulative,
758
+ 'previous': fresh.filled_qty,
759
+ 'execution_ids': list(confirmation.execution_ids),
760
+ },
761
+ exchange_order_id=(confirmation.position_ref
762
+ or fresh.exchange_order_id),
763
+ ),
764
+ )
765
+
766
+ def _build_fill_event(
767
+ self,
768
+ updated: 'OrderRow',
769
+ confirmation: MissingConfirmation,
770
+ cumulative: float,
771
+ fill_qty: float,
772
+ now_ts: float,
773
+ ) -> OrderEvent:
774
+ if self._fill_event_factory is not None:
775
+ return self._fill_event_factory(
776
+ updated, confirmation, cumulative, fill_qty, now_ts,
777
+ )
778
+ return self._default_fill_event(
779
+ updated, confirmation, cumulative, fill_qty, now_ts,
780
+ )
781
+
782
+ def _warn_deferred(self, row: 'OrderRow', event_kind: str, msg: str) -> None:
783
+ """Log once per row per reason that a grace-expired retire deferred."""
784
+ coid = row.client_order_id
785
+ key = (coid, event_kind)
786
+ if key in self._warned_deferred:
787
+ return
788
+ logger.warning(msg, coid)
789
+ self._store.log_event(
790
+ event_kind,
791
+ client_order_id=coid,
792
+ exchange_order_id=row.exchange_order_id,
793
+ )
794
+ # Arm the throttle only after the audit event is persisted, so a
795
+ # failed log_event does not mute a later genuine re-warning.
796
+ self._warned_deferred.add(key)
797
+
798
+ def _forget_deferred(self, coid: str) -> None:
799
+ """Drop every deferred-warning throttle for a row (it resolved)."""
800
+ self._warned_deferred = {
801
+ k for k in self._warned_deferred if k[0] != coid
802
+ }
803
+
804
+ # --- Policy ---------------------------------------------------------------
805
+
806
+ async def _apply_policy(self, row: 'OrderRow') -> None:
807
+ """Apply the ``on_unexpected_cancel`` policy for a confirmed cancel.
808
+
809
+ Runs BEFORE the cancelled event is yielded: the sweep and the
810
+ quarantine / halt decision must not depend on the consumer pulling
811
+ further elements. ``stop`` and ``stop_and_cancel`` latch the
812
+ engine's quarantine through the ``request_quarantine`` hook — the
813
+ process (and this observation loop) keeps running; a missing or
814
+ raising hook falls back to arming the halt, never to continued
815
+ trading. ``halt`` always arms :attr:`pending_halt`. Either signal
816
+ is armed BEFORE the best-effort sweep, so a raising sweep can
817
+ never swallow it; a pending halt is raised after the row's events
818
+ were yielded (or at the top of the next pass, when the generator
819
+ was abandoned).
820
+ """
821
+ reason = (
822
+ f"Bot-owned order disappeared unexpectedly: "
823
+ f"coid={row.client_order_id!r} "
824
+ f"ref={row.exchange_order_id!r}"
825
+ )
826
+ context = {
827
+ 'client_order_id': row.client_order_id,
828
+ 'exchange_order_id': row.exchange_order_id,
829
+ 'symbol': row.symbol,
830
+ 'policy': self._policy,
831
+ }
832
+ halt = resolve_unexpected_cancel_policy(
833
+ self._policy,
834
+ reason=reason,
835
+ context=context,
836
+ request_quarantine=self._request_quarantine,
837
+ log_event=self._store.log_event,
838
+ client_order_id=row.client_order_id,
839
+ exchange_order_id=row.exchange_order_id,
840
+ )
841
+ if halt is not None:
842
+ self._pending_halt = halt
843
+ if self._policy == 'stop_and_cancel' and self._cancel_siblings is not None:
844
+ # noinspection PyBroadException
845
+ try:
846
+ await self._cancel_siblings(row)
847
+ except Exception:
848
+ logger.exception(
849
+ "cancel_siblings sweep failed for %r; quarantine/halt "
850
+ "stays armed",
851
+ row.client_order_id,
852
+ )
853
+ self._store.log_event(
854
+ 'unexpected_cancel_sweep_failed',
855
+ client_order_id=row.client_order_id,
856
+ exchange_order_id=row.exchange_order_id,
857
+ )
858
+
859
+ # --- Default event builders ------------------------------------------------
860
+
861
+ def _build_cancelled_event(
862
+ self, row: 'OrderRow', now_ts: float,
863
+ ) -> OrderEvent:
864
+ if self._cancelled_event_factory is not None:
865
+ event = self._cancelled_event_factory(row, now_ts)
866
+ else:
867
+ event = OrderEvent(
868
+ order=ExchangeOrder(
869
+ id=row.exchange_order_id or '', symbol=row.symbol,
870
+ side=row.side, order_type=OrderType.MARKET,
871
+ qty=row.qty, filled_qty=row.filled_qty,
872
+ remaining_qty=max(0.0, row.qty - row.filled_qty),
873
+ price=None, stop_price=None, average_fill_price=None,
874
+ status=OrderStatus.CANCELLED, timestamp=now_ts, fee=0.0,
875
+ fee_currency='', reduce_only=False,
876
+ client_order_id=row.client_order_id,
877
+ ),
878
+ event_type='cancelled',
879
+ fill_price=None, fill_qty=None, timestamp=now_ts,
880
+ pine_id=row.pine_entry_id, from_entry=row.from_entry,
881
+ leg_type=LegType.ENTRY,
882
+ )
883
+ # Stamp the tracker-origin marker on both the default and the
884
+ # plugin-supplied event: the policy already ran in
885
+ # :meth:`_apply_policy` before this event is emitted, so the sync
886
+ # engine's WS-push handler must NOT re-apply it.
887
+ event.from_disappearance_tracker = True
888
+ return event
889
+
890
+ @staticmethod
891
+ def _default_fill_event(
892
+ row: 'OrderRow',
893
+ confirmation: MissingConfirmation,
894
+ cumulative: float,
895
+ fill_qty: float,
896
+ now_ts: float,
897
+ ) -> OrderEvent:
898
+ full = cumulative >= row.qty - _QTY_EPS
899
+ timestamp = confirmation.executed_ts or now_ts
900
+ # A lone backing execution id is a stable duplicate-fill key; an
901
+ # aggregate over several executions has none — the plugin's own
902
+ # dedup channel (via ``register_executions``) must cover it.
903
+ fill_id = (confirmation.execution_ids[0]
904
+ if len(confirmation.execution_ids) == 1 else None)
905
+ return OrderEvent(
906
+ order=ExchangeOrder(
907
+ id=(confirmation.position_ref
908
+ or row.exchange_order_id or ''),
909
+ symbol=row.symbol, side=row.side,
910
+ order_type=OrderType.MARKET,
911
+ qty=row.qty, filled_qty=cumulative,
912
+ remaining_qty=max(0.0, row.qty - cumulative),
913
+ price=None, stop_price=None,
914
+ average_fill_price=confirmation.fill_price,
915
+ status=(OrderStatus.FILLED if full
916
+ else OrderStatus.PARTIALLY_FILLED),
917
+ timestamp=timestamp, fee=confirmation.fill_fee,
918
+ fee_currency='', reduce_only=False,
919
+ client_order_id=row.client_order_id,
920
+ ),
921
+ event_type='filled' if full else 'partial',
922
+ fill_price=confirmation.fill_price, fill_qty=fill_qty,
923
+ timestamp=timestamp,
924
+ pine_id=row.pine_entry_id, from_entry=row.from_entry,
925
+ leg_type=LegType.ENTRY, fee=confirmation.fill_fee,
926
+ fill_id=fill_id,
927
+ )