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,1785 @@
1
+ """
2
+ Crash-safe dispatch journal for the broker-runtime layer.
3
+
4
+ Broker plugins currently encode their own persist-first state machine
5
+ for every ``execute_*`` call: write the order row, log the audit
6
+ event, POST to the exchange, parse the response, mirror the server
7
+ reference, confirm-readback, finalize the row. The Capital.com plugin
8
+ has six methods that re-implement the same nine-step pattern with
9
+ exchange-specific endpoints, response shapes, and reject codes
10
+ sprinkled between them.
11
+
12
+ :class:`DispatchJournal` owns the persist-first state machine. The
13
+ plugin provides typed hooks for the parts that genuinely differ
14
+ between exchanges: request shape, response parsing, reject
15
+ classification. The journal coordinates writes against the
16
+ :class:`~pynecore.core.broker.storage.RunContext` through the typed
17
+ helpers in :mod:`pynecore.core.broker.store_helpers`, so the canonical
18
+ ``extras`` schema and the order-of-writes invariants live in exactly
19
+ one place.
20
+
21
+ This module is the M1 proof-of-shape — entry dispatch only, no
22
+ brackets, no modify. It is shipped *alongside* the existing plugin
23
+ state machine so the parity test can compare both paths byte-for-byte
24
+ before any production code path is rewritten. The replacement plan is
25
+ documented in ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``.
26
+ """
27
+ from collections.abc import Mapping
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, Literal, Protocol, TYPE_CHECKING
30
+
31
+ from pynecore.core.broker.exceptions import (
32
+ ExchangeOrderRejectedError,
33
+ OrderDispositionUnknownError,
34
+ )
35
+ from pynecore.core.broker.models import (
36
+ CancelIntent,
37
+ CloseIntent,
38
+ EntryIntent,
39
+ ExchangeOrder,
40
+ ExitIntent,
41
+ )
42
+ from pynecore.core.broker.store_helpers import (
43
+ KIND_CANCEL,
44
+ KIND_FULL_CLOSE,
45
+ KIND_MODIFY_ENTRY,
46
+ KIND_MODIFY_EXIT,
47
+ KIND_PARTIAL_CLOSE,
48
+ create_cancel_command_row,
49
+ create_close_target_row,
50
+ create_entry_order_row,
51
+ create_modify_entry_row,
52
+ create_modify_exit_row,
53
+ find_pending_dispatch,
54
+ mark_cancel_completed,
55
+ mark_close_completed,
56
+ mark_closing,
57
+ mark_confirmed_with_fill,
58
+ mark_disposition_unknown,
59
+ mark_modify_completed,
60
+ mark_reconcile_filled,
61
+ mark_reconcile_terminal_close,
62
+ mark_rejected,
63
+ record_close_server_ref,
64
+ record_server_ref,
65
+ )
66
+
67
+ if TYPE_CHECKING:
68
+ from pynecore.core.broker.storage import OrderRow, RunContext
69
+
70
+ __all__ = [
71
+ 'DispatchJournal',
72
+ 'EntryDispatchHooks',
73
+ 'CloseDispatchHooks',
74
+ 'CancelDispatchHooks',
75
+ 'ModifyEntryDispatchHooks',
76
+ 'ModifyExitDispatchHooks',
77
+ 'SubmitOutcome',
78
+ 'ConfirmOutcome',
79
+ 'ResumeOutcome',
80
+ 'ResumeStatus',
81
+ 'CloseOutcome',
82
+ 'CancelOutcome',
83
+ 'CancelReasonPath',
84
+ 'ModifyEntryOutcome',
85
+ 'ModifyExitOutcome',
86
+ 'ModifyExitStatus',
87
+ 'ReconcileOutcome',
88
+ 'ReconcileKind',
89
+ 'ReconcileReason',
90
+ 'ReconcileTerminalState',
91
+ 'PendingResolution',
92
+ 'PendingHooksProvider',
93
+ ]
94
+
95
+
96
+ # === Hook return types =====================================================
97
+
98
+ @dataclass(frozen=True)
99
+ class SubmitOutcome:
100
+ """Plugin's successful ``submit()`` result.
101
+
102
+ On any failure mode (timeout, missing reference, synchronous
103
+ reject) the hook raises an appropriate
104
+ :class:`~pynecore.core.broker.exceptions.BrokerError` subclass
105
+ instead of returning. The journal converts those raises to the
106
+ matching persisted state.
107
+
108
+ :ivar server_ref: Exchange-allocated reference for the submission
109
+ (``dealReference`` for Capital.com, ``orderLinkId`` echo for
110
+ Bybit, etc.). Persisted into ``order_refs`` under
111
+ ``ref_type='deal_reference'``.
112
+ :ivar raw: Verbatim exchange response, attached to the
113
+ ``deal_reference_seen`` audit event for forensics. ``None``
114
+ when the plugin does not want to expose the raw body.
115
+ """
116
+ server_ref: str
117
+ raw: dict | None = None
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ConfirmOutcome:
122
+ """Plugin's successful ``confirm_submission()`` result.
123
+
124
+ On a confirm-REJECTED outcome the hook raises
125
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
126
+ (or a subclass) so the journal can persist the rejection. On a
127
+ confirm-timeout it raises
128
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`.
129
+
130
+ :ivar exchange_id: Exchange-allocated id for the resulting order
131
+ / position (``dealId``, ``orderId``, ...). ``None`` when the
132
+ exchange returns no id at confirm time — the journal still
133
+ advances state but skips the ``deal_id`` ref.
134
+ :ivar is_filled: ``True`` only for MARKET-side fills that confirm
135
+ as OPEN. LIMIT / STOP submissions confirm as ACCEPTED but not
136
+ filled; their fills arrive later via the activity stream.
137
+ :ivar filled_qty: Confirmed fill quantity. Ignored unless
138
+ ``is_filled`` is ``True``.
139
+ :ivar fill_price: Confirm-side fill price. Persisted into
140
+ ``extras['confirm_level']`` only when strictly positive (a
141
+ zero/negative level is a no-quote artefact and would corrupt
142
+ the recovery fallback).
143
+ :ivar raw: Verbatim confirm response, attached to the
144
+ ``confirmed`` audit event for forensics.
145
+ """
146
+ exchange_id: str | None
147
+ is_filled: bool
148
+ filled_qty: float = 0.0
149
+ fill_price: float | None = None
150
+ raw: dict | None = None
151
+
152
+
153
+ CancelReasonPath = Literal['deleted', 'already_gone', 'noop', 'recovered']
154
+ ModifyExitStatus = Literal['ACCEPTED', 'REJECTED']
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class CloseOutcome:
159
+ """Plugin's successful close-dispatch result.
160
+
161
+ Returned from the plugin's ``submit_full_close`` or
162
+ ``submit_partial_close`` hook. On a confirm-REJECTED outcome the
163
+ hook raises
164
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`;
165
+ on a network / disposition-unknown outcome it raises
166
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`.
167
+
168
+ :ivar mode: ``'full'`` for a full-close DELETE chain, ``'partial'``
169
+ for the partial-close emulated POST. The journal also receives
170
+ the ``kind`` argument up-front; the field is echoed here so a
171
+ single outcome shape covers both branches.
172
+ :ivar applied_targets: Exchange ``dealId`` strings the dispatch
173
+ actually touched. Full close: every target the DELETE chain
174
+ completed against. Partial close: a single-element list with
175
+ the newly-opened opposite leg's ``dealId``, or empty if the
176
+ POST returned no id.
177
+ :ivar deal_reference: Server-allocated POST reference. Only the
178
+ partial-close branch carries one; full-close returns ``None``.
179
+ :ivar exchange_id: Single representative exchange id for the
180
+ :class:`ExchangeOrder` the engine receives. For full close
181
+ this is the first target's ``dealId``; for partial close this
182
+ is the new opposite leg's ``dealId``.
183
+ :ivar filled_qty: Quantity reported as filled by the broker
184
+ response (or synthesised from the intent for the full-close
185
+ synchronous flow).
186
+ :ivar fill_price: Confirm-side price when known. ``None`` when
187
+ the broker did not echo a fill price.
188
+ :ivar raw: Verbatim broker response for forensics.
189
+ """
190
+ mode: Literal['full', 'partial']
191
+ applied_targets: list[str]
192
+ deal_reference: str | None = None
193
+ exchange_id: str | None = None
194
+ filled_qty: float = 0.0
195
+ fill_price: float | None = None
196
+ raw: dict | None = None
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class CancelOutcome:
201
+ """Plugin's successful cancel-dispatch result.
202
+
203
+ :ivar succeeded: ``True`` once the per-target sweep finished
204
+ (including the benign already-gone path). ``False`` is
205
+ currently unused — cancel failures raise rather than return.
206
+ :ivar reason_path: Why the cancel resolved this way:
207
+
208
+ - ``'deleted'`` — at least one target was actively swept by
209
+ the dispatch.
210
+ - ``'already_gone'`` — every target had already vanished from
211
+ the broker; nothing was DELETEd.
212
+ - ``'noop'`` — no targets matched the intent at all.
213
+ - ``'recovered'`` — recovery declared the cancel landed because
214
+ all targets vanished from the snapshots; never emitted by a
215
+ live dispatch, only by :meth:`DispatchJournal._apply_resume_outcome`.
216
+
217
+ :ivar cleared_legs: Number of bracket / working-order legs the
218
+ dispatch swept (``len(applied_target_coids)``).
219
+ :ivar applied_target_coids: Plugin-side COIDs the dispatch closed.
220
+ Persisted into ``extras['applied_target_coids']`` so recovery
221
+ can reason about which targets the per-target loop actually
222
+ reached before any crash.
223
+ :ivar raw: Verbatim broker response (or aggregated responses) for
224
+ forensics.
225
+ """
226
+ succeeded: bool
227
+ reason_path: CancelReasonPath
228
+ cleared_legs: int
229
+ applied_target_coids: list[str]
230
+ raw: dict | None = None
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class ModifyEntryOutcome:
235
+ """Plugin's successful working-order amend result.
236
+
237
+ :ivar server_ref: ``dealReference`` of the amend PUT. Persisted
238
+ under ``order_refs['deal_reference']`` so recovery can verify
239
+ the change landed via a confirm GET.
240
+ :ivar new_level: Echoed back from the confirm response — the
241
+ broker's view of the amended level. Compared against the
242
+ intent's requested level on recovery to detect drift.
243
+ :ivar raw: Verbatim confirm response for forensics.
244
+ """
245
+ server_ref: str
246
+ new_level: float
247
+ raw: dict | None = None
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class ModifyExitOutcome:
252
+ """Plugin's successful position bracket amend result.
253
+
254
+ :ivar server_ref: ``dealReference`` of the amend PUT.
255
+ :ivar deal_status: ``'ACCEPTED'`` for happy path,
256
+ ``'REJECTED'`` is converted to
257
+ :class:`ExchangeOrderRejectedError` at the hook boundary so
258
+ the journal can persist the rejection; the field exists so
259
+ forensics see the broker's exact verdict.
260
+ :ivar rejected_reason: Free-form reject reason copied from the
261
+ confirm response when ``deal_status == 'REJECTED'``.
262
+ :ivar post_put_state: Mapping of the broker-echoed levels the
263
+ plugin's ``mirror_bracket_legs`` callback needs to materialise
264
+ the synthetic leg rows post-success. Keys are plugin-defined
265
+ (e.g. ``'profit_level'``, ``'stop_level'``,
266
+ ``'trailing_stop'``).
267
+ :ivar raw: Verbatim confirm response for forensics.
268
+ """
269
+ server_ref: str
270
+ deal_status: ModifyExitStatus
271
+ rejected_reason: str | None = None
272
+ post_put_state: Mapping[str, Any] = field(default_factory=dict)
273
+ raw: dict | None = None
274
+
275
+
276
+ ResumeStatus = Literal['confirmed', 'rejected', 'still_unknown']
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class ResumeOutcome:
281
+ """Plugin's verdict on a pending dispatch found at restart.
282
+
283
+ :ivar status: ``'confirmed'`` when the plugin verified the
284
+ submission landed (exchange shows the order / position).
285
+ ``'rejected'`` when the plugin verified it did not land
286
+ (activity stream, snapshot, or confirm GET says so).
287
+ ``'still_unknown'`` when the plugin cannot decide yet — the
288
+ journal leaves the row as-is and the engine's pending-
289
+ verification reconciler tries again on the next sync.
290
+ :ivar exchange_id: Same semantics as :class:`ConfirmOutcome`.
291
+ :ivar is_filled: Same semantics as :class:`ConfirmOutcome`.
292
+ :ivar filled_qty: Same semantics as :class:`ConfirmOutcome`.
293
+ :ivar fill_price: Same semantics as :class:`ConfirmOutcome`.
294
+ :ivar reject_reason: Free-form reject reason, written to the audit
295
+ event when ``status == 'rejected'``.
296
+ :ivar recovery_path: Plugin-defined category for the resolution
297
+ route — e.g. ``'stored_ref'``, ``'activity_single_match'``,
298
+ ``'ttl_fallback_snapshot'``, ``'confirm_get_direct'``. Merged
299
+ into the ``recovered_*`` / ``recovery_pending`` audit event
300
+ payload when set. The Core does not validate the value; the
301
+ plugin owns the taxonomy.
302
+ :ivar recovery_context: Structured plugin diagnostic that goes
303
+ alongside ``recovery_path`` in the audit event (e.g.
304
+ ``{'matched_snapshot': 'working', 'activity_count': 1}``).
305
+ """
306
+ status: ResumeStatus
307
+ exchange_id: str | None = None
308
+ is_filled: bool = False
309
+ filled_qty: float = 0.0
310
+ fill_price: float | None = None
311
+ reject_reason: str | None = None
312
+ recovery_path: str | None = None
313
+ recovery_context: Mapping[str, Any] | None = None
314
+
315
+
316
+ # === Reconcile-path outcome ================================================
317
+
318
+ ReconcileKind = Literal['filled', 'terminal_close']
319
+
320
+ # Validated reason tags for :class:`ReconcileOutcome`. New reasons require
321
+ # extending this literal; the journal does not enforce membership at
322
+ # runtime, but downstream tests and broker-plugin reviews check against
323
+ # this taxonomy. See ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``
324
+ # §4.2 for the full contract.
325
+ ReconcileReason = Literal[
326
+ # kind='filled'
327
+ 'working_promoted_position',
328
+ 'partial_fill_progress',
329
+ # kind='terminal_close'
330
+ 'bracket_sibling_retired_on_mixed_rejection',
331
+ 'pending_trail_parent_rejected',
332
+ 'missing_pending_grace_expired',
333
+ 'unexpected_cancel_cascade',
334
+ 'bracket_natural_close_followup',
335
+ # kind='terminal_close' — startup in-flight recovery (persist-first crash
336
+ # recovery): a pending dispatch row read terminal from the order history,
337
+ # or a still-unknown row retired after the evidence-gated TTL.
338
+ 'recovered_in_flight_terminal',
339
+ 'recovered_abandoned_unknown',
340
+ ]
341
+
342
+ # Terminal states the reconcile path may land a row in. ``'confirmed'`` is
343
+ # used by the working→position promotion (``kind='filled'``);
344
+ # ``'rejected'`` is the typical destination for bracket sibling retires and
345
+ # the cascade paths; ``'closed'`` is reserved for paths that already pass
346
+ # through a non-terminal state and merely need :meth:`RunContext.close_order`
347
+ # (current call sites use ``state='rejected'`` plus ``close_row=True``, but
348
+ # the literal is here for the bracket-natural-close follow-up reason).
349
+ ReconcileTerminalState = Literal['confirmed', 'rejected', 'closed']
350
+
351
+
352
+ @dataclass(frozen=True)
353
+ class ReconcileOutcome:
354
+ """Plugin verdict for one reconcile-path terminal mutation.
355
+
356
+ Emitted per-row by the plugin reconciler (``_reconcile_snapshot``,
357
+ ``_missing_pending_tracker``, ``_maybe_raise_unexpected_cancel``) when
358
+ an observation, grace-window expiry, or cascade rule requires the
359
+ journal to persist a terminal lifecycle change. The journal owns the
360
+ actual ``state`` / ``filled_qty`` / ``closed_ts_ms`` / audit-event
361
+ writes; the plugin only declares "what happened and why".
362
+
363
+ The Cat 1 reconciler-private observation breadcrumbs
364
+ (``missing_pending_since``, ``close_event_yielded_at``,
365
+ ``close_event_yielded_at_poll_id``) are NOT routed through this
366
+ outcome — they stay plugin-direct writes. Journal ownership is
367
+ limited to ``state`` / ``filled_qty`` / ``closed_ts_ms`` / terminal
368
+ timestamps / lifecycle audit events; everything else under
369
+ ``extras`` is the plugin's namespace.
370
+
371
+ :ivar kind: ``'filled'`` for the working→position fill detection
372
+ (a row in :data:`~pynecore.core.broker.store_helpers.STATE_SERVER_REF_SEEN`
373
+ observed in ``/positions``). ``'terminal_close'`` for any other
374
+ reconcile-path retirement — bracket sibling retire, grace-window
375
+ expiry, unexpected-cancel cascade, eager-teardown follow-up.
376
+ :ivar reason: Validated literal tag describing the trigger; see
377
+ :data:`ReconcileReason`.
378
+ :ivar new_state: The state the row should land in. ``'confirmed'``
379
+ for ``kind='filled'``; ``'rejected'`` / ``'closed'`` for
380
+ ``kind='terminal_close'``.
381
+ :ivar filled_qty: Fill quantity (mandatory when ``kind='filled'``;
382
+ ignored when ``kind='terminal_close'``).
383
+ :ivar extras_patch: Plugin-supplied extras to merge into the row in
384
+ the same transaction as the state mutation. Examples:
385
+ ``{'kind': 'position', 'entry_filled_at': now_ts}`` for a
386
+ working→position fill, ``None`` for bracket retire. The journal
387
+ does not validate the keys.
388
+ :ivar close_row: ``True`` => journal also calls
389
+ :meth:`RunContext.close_order` to retire the row from
390
+ ``iter_live_orders``. ``False`` for working→position (the row
391
+ stays live as a position); ``True`` for the bracket / cascade /
392
+ grace-expiry paths.
393
+ :ivar audit_event: The :meth:`RunContext.log_event` ``kind`` for the
394
+ audit row the journal writes after the state mutation. Plugin
395
+ chooses the name (the broker tests pin specific event names).
396
+ :ivar audit_payload: Mapping merged into the audit event payload.
397
+ :ivar exchange_order_id: Optional exchange-side id stamped into the
398
+ audit event (e.g. ``parent_deal_id`` for a bracket sibling
399
+ retire).
400
+ """
401
+ kind: ReconcileKind
402
+ reason: ReconcileReason
403
+ new_state: ReconcileTerminalState
404
+ audit_event: str
405
+ filled_qty: float | None = None
406
+ extras_patch: Mapping[str, Any] | None = None
407
+ close_row: bool = False
408
+ audit_payload: Mapping[str, Any] | None = None
409
+ exchange_order_id: str | None = None
410
+
411
+
412
+ # === Hook protocol =========================================================
413
+
414
+ class EntryDispatchHooks(Protocol):
415
+ """Plugin-supplied callbacks for an entry dispatch.
416
+
417
+ A fresh hook instance is constructed by the plugin per dispatch.
418
+ The journal owns persistence and state transitions; hooks own the
419
+ exchange wire format, response parsing, and rejection
420
+ classification.
421
+
422
+ All four methods are mandatory. The ``async`` methods may raise
423
+ any :class:`~pynecore.core.broker.exceptions.BrokerError`
424
+ subclass; the journal catches them and persists the matching
425
+ state before re-raising.
426
+ """
427
+
428
+ async def submit(
429
+ self, *, coid: str, intent: EntryIntent, qty: float,
430
+ ) -> SubmitOutcome:
431
+ """Submit the order to the exchange.
432
+
433
+ Implementations issue exactly one REST / WS call and return a
434
+ :class:`SubmitOutcome` on success, OR raise
435
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
436
+ on network timeout / missing server reference, OR raise
437
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
438
+ when the POST itself produces a definitive synchronous reject
439
+ (e.g. a 4xx with a parseable reason and no server reference to
440
+ confirm against). The journal converts both raises into the
441
+ matching terminal / pending state. Implementations MUST NOT
442
+ write any persistence — the journal owns that.
443
+
444
+ The preferred pattern remains "defer rejection to
445
+ :meth:`confirm_submission`" because most exchanges do issue a
446
+ server reference even for soon-to-be-rejected orders, and the
447
+ confirm phase carries richer reason data. Raise
448
+ :class:`ExchangeOrderRejectedError` here only when the POST
449
+ response itself is the final word.
450
+ """
451
+ ...
452
+
453
+ async def confirm_submission(
454
+ self, *, coid: str, intent: EntryIntent, server_ref: str,
455
+ ) -> ConfirmOutcome:
456
+ """Read back the confirmation for a recorded server reference.
457
+
458
+ Implementations issue exactly one REST / WS call and return a
459
+ :class:`ConfirmOutcome` on success, OR raise
460
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
461
+ on synchronous reject, OR
462
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
463
+ on confirm-timeout / unparseable response. They MUST NOT
464
+ write any persistence — the journal owns that.
465
+
466
+ For exchanges that have no separate confirm step (server
467
+ echoes the full result on POST), implement this as a pure
468
+ function that synthesises a :class:`ConfirmOutcome` from data
469
+ the plugin cached during ``submit``.
470
+ """
471
+ ...
472
+
473
+ def exchange_order_from_state(
474
+ self, *, row: 'OrderRow', intent: EntryIntent,
475
+ ) -> ExchangeOrder:
476
+ """Build the :class:`ExchangeOrder` the engine expects.
477
+
478
+ The journal calls this *after* :class:`ConfirmOutcome` has
479
+ been persisted, so the row carries the final fill state.
480
+ Pure function — no I/O, no persistence writes.
481
+ """
482
+ ...
483
+
484
+ async def resume_pending_dispatch(
485
+ self, *, row: 'OrderRow', refs: Mapping[str, str],
486
+ ) -> ResumeOutcome:
487
+ """Decide the disposition of a pre-restart pending row.
488
+
489
+ Called once per ``find_pending_dispatch`` hit during
490
+ :meth:`DispatchJournal.recover_pending`. The plugin checks
491
+ the exchange's authoritative view (activity stream, snapshot,
492
+ confirm GET) and returns the corresponding
493
+ :class:`ResumeOutcome`.
494
+
495
+ :param row: The persisted row, including its ``extras`` dict.
496
+ :param refs: Mapping of ``ref_type`` → ``ref_value`` already
497
+ recorded for this COID (``'deal_reference'`` is the
498
+ typical entry; ``'deal_id'`` may also be present when the
499
+ crash happened after server-ref-seen but before
500
+ confirmed).
501
+ """
502
+ ...
503
+
504
+
505
+ class CloseDispatchHooks(Protocol):
506
+ """Plugin-supplied callbacks for a close dispatch.
507
+
508
+ Exactly one of :meth:`submit_full_close` and
509
+ :meth:`submit_partial_close` is invoked per dispatch, decided by
510
+ the ``kind`` argument the journal receives. The other method is
511
+ not required to do anything meaningful — implementations typically
512
+ raise ``RuntimeError`` from the unused one as a defensive marker.
513
+ """
514
+
515
+ async def submit_full_close(
516
+ self, *, coid: str, intent: CloseIntent,
517
+ targets: list['OrderRow'],
518
+ ) -> CloseOutcome:
519
+ """DELETE every target position. Synchronous fill.
520
+
521
+ ``targets`` are the live position rows the dispatch must
522
+ close. Implementations issue one DELETE per target and return
523
+ a :class:`CloseOutcome` with ``mode='full'`` and
524
+ ``applied_targets`` listing the ``dealId`` of every
525
+ successfully DELETEd position. On a benign already-gone race
526
+ (404) targets may be omitted from ``applied_targets``; the
527
+ recovery contract treats vanished targets as confirmed.
528
+
529
+ Implementations MUST NOT mutate the close command row's state
530
+ — only the journal does that. They MAY mutate the *target*
531
+ rows (e.g. ``store.set_order_state(target_coid, 'closing')``)
532
+ because those rows live outside the journal's command-row
533
+ scope.
534
+
535
+ Network / timeout errors raise
536
+ :class:`OrderDispositionUnknownError`; explicit broker rejects
537
+ raise :class:`ExchangeOrderRejectedError`.
538
+ """
539
+ ...
540
+
541
+ async def submit_partial_close(
542
+ self, *, coid: str, intent: CloseIntent,
543
+ ) -> CloseOutcome:
544
+ """Emulated partial close via opposite-direction POST.
545
+
546
+ Implementations issue a single POST (Capital.com has no native
547
+ partial-close endpoint), record the ``dealReference``, and
548
+ reconcile pre/post position snapshots to detect any race
549
+ against an unrelated opposite-side opening. Returns a
550
+ :class:`CloseOutcome` with ``mode='partial'``,
551
+ ``deal_reference`` populated, ``exchange_id`` set to the new
552
+ opposite-leg ``dealId``, and ``applied_targets`` listing that
553
+ single ``dealId``. The hook itself raises
554
+ :class:`BrokerManualInterventionError` on an unresolved race;
555
+ the journal does not catch that — it propagates to the engine.
556
+ """
557
+ ...
558
+
559
+ def exchange_order_from_state(
560
+ self, *, row: 'OrderRow', intent: CloseIntent,
561
+ outcome: CloseOutcome,
562
+ ) -> ExchangeOrder:
563
+ """Build the :class:`ExchangeOrder` the engine expects.
564
+
565
+ Called once the command row has reached its terminal state
566
+ (``closing`` for full, ``confirmed`` for partial). Pure
567
+ function — no I/O.
568
+ """
569
+ ...
570
+
571
+
572
+ class CancelDispatchHooks(Protocol):
573
+ """Plugin-supplied callbacks for a cancel dispatch.
574
+
575
+ Cancel has no submit/confirm split — the plugin sweeps all targets
576
+ in a single call and returns the outcome. The journal owns the
577
+ command-row state transitions; the plugin owns the per-target
578
+ REST operations and the target-row mutations.
579
+ """
580
+
581
+ async def submit_cancel(
582
+ self, *, coid: str, intent: CancelIntent,
583
+ targets: list['OrderRow'],
584
+ ) -> CancelOutcome:
585
+ """Sweep every target. Returns a single :class:`CancelOutcome`.
586
+
587
+ Implementations issue the per-target REST calls (DELETE for
588
+ working orders, PUT-null for bracket legs) and mark the
589
+ target rows closed via ``store.close_order(target_coid)``.
590
+ Benign already-gone (404) responses are absorbed without
591
+ raising — the resulting :attr:`CancelOutcome.reason_path`
592
+ reflects whether any actual DELETE happened.
593
+
594
+ Implementations MUST NOT mutate the cancel command row's
595
+ state — only the journal does that.
596
+ """
597
+ ...
598
+
599
+ def exchange_order_from_state(
600
+ self, *, row: 'OrderRow', intent: CancelIntent,
601
+ outcome: CancelOutcome,
602
+ ) -> ExchangeOrder:
603
+ """Build the synthetic :class:`ExchangeOrder` for the cancel.
604
+
605
+ Cancel does not produce an exchange order per se, but the
606
+ engine signature expects one. The hook synthesises a
607
+ :class:`OrderStatus.CANCELLED` order so the caller can plumb
608
+ the outcome through unchanged channels.
609
+ """
610
+ ...
611
+
612
+
613
+ class ModifyEntryDispatchHooks(Protocol):
614
+ """Plugin-supplied callbacks for a working-order amend dispatch."""
615
+
616
+ async def submit_amend(
617
+ self, *, coid: str, target_coid: str,
618
+ old_intent: EntryIntent, new_intent: EntryIntent,
619
+ ) -> ModifyEntryOutcome:
620
+ """PUT the new level and confirm.
621
+
622
+ Returns a :class:`ModifyEntryOutcome` with the broker-echoed
623
+ ``new_level``. On reject raises
624
+ :class:`ExchangeOrderRejectedError`; on timeout raises
625
+ :class:`OrderDispositionUnknownError`. The amend target row
626
+ (the working order itself) is mutated by the hook via
627
+ ``store.upsert_order(target_coid, ...)`` because it lives
628
+ outside the journal's command-row scope.
629
+ """
630
+ ...
631
+
632
+ def exchange_order_from_state(
633
+ self, *, row: 'OrderRow', new_intent: EntryIntent,
634
+ outcome: ModifyEntryOutcome,
635
+ ) -> list[ExchangeOrder]:
636
+ """Build the :class:`ExchangeOrder` list for the engine.
637
+
638
+ The engine's :meth:`modify_entry` signature returns a list of
639
+ orders; for atomic amends this is a one-element list pointing
640
+ at the same target as before. Pure function.
641
+ """
642
+ ...
643
+
644
+
645
+ class ModifyExitDispatchHooks(Protocol):
646
+ """Plugin-supplied callbacks for a position bracket amend dispatch.
647
+
648
+ Modify-exit is the most complex dispatch — the plugin's
649
+ ``prepare()`` logic decides the new TP / SL / trailing levels and
650
+ seeds any newly-added bracket leg rows in
651
+ ``disposition_unknown``. The journal then drives the entry-row
652
+ audit trail and the ``mirror_bracket_legs`` callback that
653
+ materialises the synthetic legs on success.
654
+ """
655
+
656
+ async def submit_amend(
657
+ self, *, coid: str, target_coid: str,
658
+ old_intent: ExitIntent, new_intent: ExitIntent,
659
+ ) -> ModifyExitOutcome:
660
+ """PUT the new bracket and confirm.
661
+
662
+ On the happy path returns a :class:`ModifyExitOutcome` with
663
+ ``deal_status='ACCEPTED'`` and ``post_put_state`` filled.
664
+ On reject raises :class:`ExchangeOrderRejectedError`; on
665
+ timeout raises :class:`OrderDispositionUnknownError`. Before
666
+ raising, the hook flips any leg rows it pre-seeded into the
667
+ appropriate disposition-unknown state and persists the
668
+ attempted target levels under the leg rows' extras — those
669
+ side-channel writes are part of the hook's responsibility.
670
+ """
671
+ ...
672
+
673
+ def mirror_bracket_legs(
674
+ self, *, target_row: 'OrderRow', new_intent: ExitIntent,
675
+ outcome: ModifyExitOutcome,
676
+ ) -> None:
677
+ """Materialise synthetic TP / SL leg rows after success.
678
+
679
+ Invoked by the journal only on the happy path (after the
680
+ entry-side command row has transitioned to ``confirmed``).
681
+ On any ambiguous / reject path the journal does NOT call
682
+ this hook; the disposition-unknown leg seeds the
683
+ ``submit_amend`` hook already wrote remain the source of
684
+ truth for recovery. Pure plugin-side write — uses
685
+ ``store.upsert_order(leg_coid, ...)`` directly because the
686
+ synthetic leg rows are outside the journal's command-row
687
+ scope for M4.
688
+ """
689
+ ...
690
+
691
+ def exchange_order_from_state(
692
+ self, *, row: 'OrderRow', new_intent: ExitIntent,
693
+ outcome: ModifyExitOutcome,
694
+ ) -> list[ExchangeOrder]:
695
+ """Build the engine-facing :class:`ExchangeOrder` list.
696
+
697
+ Returns one :class:`ExchangeOrder` per active bracket leg
698
+ (TP / SL) reflecting the post-amend levels. Pure function.
699
+ """
700
+ ...
701
+
702
+
703
+ # === Journal ===============================================================
704
+
705
+ @dataclass
706
+ class DispatchJournal:
707
+ """Persist-first orchestrator for a single dispatch lifecycle.
708
+
709
+ Constructed once per :class:`~pynecore.core.broker.storage.RunContext`.
710
+ Reused across dispatches — the instance is thread-safe to the same
711
+ extent the underlying ``RunContext`` is (single writer assumption).
712
+
713
+ :param store: The active run context.
714
+ """
715
+ store: 'RunContext'
716
+
717
+ # --- Entry path --------------------------------------------------------
718
+
719
+ async def run_entry(
720
+ self,
721
+ *,
722
+ coid: str,
723
+ intent: EntryIntent,
724
+ qty: float,
725
+ kind: str,
726
+ hooks: EntryDispatchHooks,
727
+ audit_payload: dict | None = None,
728
+ ) -> list[ExchangeOrder]:
729
+ """Run an entry dispatch from initial persist through confirm.
730
+
731
+ The state-machine is :data:`STATE_SUBMITTED` → optionally
732
+ :data:`STATE_SERVER_REF_SEEN` → :data:`STATE_CONFIRMED` (or
733
+ :data:`STATE_REJECTED` / :data:`STATE_DISPOSITION_UNKNOWN` on
734
+ the failure paths). Every state advance happens through a
735
+ store helper, so the on-disk schema is identical to what the
736
+ legacy plugin path writes — the parity test relies on that.
737
+
738
+ :param coid: The dispatch's canonical client-order-id (already
739
+ derived from ``envelope.client_order_id(KIND_ENTRY)``).
740
+ :param intent: The :class:`EntryIntent` being dispatched.
741
+ :param qty: Quantity to submit, already quantized to the
742
+ broker's lot step.
743
+ :param kind: :data:`ENTRY_KIND_POSITION` for MARKET orders or
744
+ :data:`ENTRY_KIND_WORKING` for LIMIT / STOP. Decides the
745
+ ``extras['kind']`` value.
746
+ :param hooks: Plugin-supplied callbacks (see
747
+ :class:`EntryDispatchHooks`).
748
+ :param audit_payload: Extra fields merged into the
749
+ ``dispatch_submitted`` audit event payload. Optional;
750
+ typically the plugin's endpoint + body so forensics can
751
+ reconstruct the exact request.
752
+ :return: A one-element list with the resulting
753
+ :class:`ExchangeOrder` — matches the
754
+ :meth:`BrokerPlugin.execute_entry` signature.
755
+ :raises OrderDispositionUnknownError: If ``submit`` reported
756
+ an ambiguous outcome.
757
+ :raises ExchangeOrderRejectedError: If ``confirm_submission``
758
+ reported a definitive reject.
759
+ """
760
+ # (1) PERSIST submitted row + audit event.
761
+ create_entry_order_row(
762
+ self.store,
763
+ coid=coid,
764
+ symbol=intent.symbol,
765
+ side=intent.side,
766
+ qty=qty,
767
+ intent_key=intent.intent_key,
768
+ pine_entry_id=intent.pine_id,
769
+ kind=kind,
770
+ order_type=intent.order_type.value,
771
+ )
772
+ submit_payload = {'kind': kind, 'order_type': intent.order_type.value}
773
+ if audit_payload:
774
+ submit_payload.update(audit_payload)
775
+ self.store.log_event(
776
+ 'dispatch_submitted',
777
+ client_order_id=coid,
778
+ intent_key=intent.intent_key,
779
+ payload=submit_payload,
780
+ )
781
+
782
+ # (2) SUBMIT — network errors raise OrderDispositionUnknownError.
783
+ # A synchronous reject (4xx with a reason string) raises
784
+ # ExchangeOrderRejectedError; the canonical pattern is to defer
785
+ # rejection to the confirm phase, but a plugin may legitimately
786
+ # raise here when the POST itself definitively rejects (e.g. a
787
+ # well-formed exchange error response with no server reference
788
+ # to confirm against). The journal terminates the row safely
789
+ # either way — leaving it ``submitted`` would make it look
790
+ # pending and recovery would retry an order the exchange already
791
+ # rejected.
792
+ try:
793
+ submit = await hooks.submit(coid=coid, intent=intent, qty=qty)
794
+ except OrderDispositionUnknownError as exc:
795
+ mark_disposition_unknown(self.store, coid=coid)
796
+ self.store.log_event(
797
+ 'disposition_unknown',
798
+ client_order_id=coid,
799
+ intent_key=intent.intent_key,
800
+ payload={'phase': 'submit', 'reason': str(exc)},
801
+ )
802
+ raise
803
+ except ExchangeOrderRejectedError as exc:
804
+ mark_rejected(self.store, coid=coid)
805
+ self.store.log_event(
806
+ 'rejected',
807
+ client_order_id=coid,
808
+ intent_key=intent.intent_key,
809
+ payload={'phase': 'submit', 'reason': str(exc)},
810
+ )
811
+ raise
812
+
813
+ # (3) PERSIST server reference + advance state.
814
+ record_server_ref(
815
+ self.store,
816
+ coid=coid,
817
+ deal_reference=submit.server_ref,
818
+ kind=kind,
819
+ order_type=intent.order_type.value,
820
+ )
821
+ self.store.log_event(
822
+ 'deal_reference_seen',
823
+ client_order_id=coid,
824
+ payload={'deal_reference': submit.server_ref},
825
+ )
826
+
827
+ # (4) CONFIRM — reject raises ExchangeOrderRejectedError,
828
+ # timeout raises OrderDispositionUnknownError.
829
+ try:
830
+ confirm = await hooks.confirm_submission(
831
+ coid=coid, intent=intent, server_ref=submit.server_ref,
832
+ )
833
+ except ExchangeOrderRejectedError as exc:
834
+ mark_rejected(self.store, coid=coid)
835
+ self.store.log_event(
836
+ 'rejected',
837
+ client_order_id=coid,
838
+ intent_key=intent.intent_key,
839
+ payload={'reason': str(exc)},
840
+ )
841
+ raise
842
+ except OrderDispositionUnknownError as exc:
843
+ mark_disposition_unknown(self.store, coid=coid)
844
+ self.store.log_event(
845
+ 'disposition_unknown',
846
+ client_order_id=coid,
847
+ intent_key=intent.intent_key,
848
+ payload={'phase': 'confirm', 'reason': str(exc)},
849
+ )
850
+ raise
851
+
852
+ # (5) PERSIST confirmed + fill (if any).
853
+ mark_confirmed_with_fill(
854
+ self.store,
855
+ coid=coid,
856
+ exchange_id=confirm.exchange_id,
857
+ is_filled=confirm.is_filled,
858
+ filled_qty=confirm.filled_qty,
859
+ fill_price=confirm.fill_price,
860
+ )
861
+ self.store.log_event(
862
+ 'confirmed',
863
+ client_order_id=coid,
864
+ exchange_order_id=confirm.exchange_id,
865
+ intent_key=intent.intent_key,
866
+ payload={
867
+ 'is_filled': confirm.is_filled,
868
+ 'fill_price': confirm.fill_price,
869
+ },
870
+ )
871
+
872
+ # (6) Return the ExchangeOrder built from final row state.
873
+ row = self.store.get_order(coid)
874
+ if row is None:
875
+ raise RuntimeError(
876
+ f"DispatchJournal.run_entry: row vanished after confirm "
877
+ f"(coid={coid!r})"
878
+ )
879
+ return [hooks.exchange_order_from_state(row=row, intent=intent)]
880
+
881
+ # --- Close path --------------------------------------------------------
882
+
883
+ async def run_close(
884
+ self,
885
+ *,
886
+ coid: str,
887
+ intent: CloseIntent,
888
+ kind: str,
889
+ targets: list['OrderRow'],
890
+ hooks: CloseDispatchHooks,
891
+ audit_payload: dict | None = None,
892
+ ) -> ExchangeOrder:
893
+ """Run a close dispatch.
894
+
895
+ Routes between full-close (DELETE chain) and partial-close
896
+ (emulated POST) based on ``kind``. Each branch persists the
897
+ command row, calls the matching hook, and finalises the row.
898
+ Target-row mutations live in the hook, since they are outside
899
+ the command-row state-machine the journal owns.
900
+
901
+ :param coid: Close dispatch COID.
902
+ :param intent: The :class:`CloseIntent` being dispatched.
903
+ :param kind: :data:`KIND_FULL_CLOSE` or
904
+ :data:`KIND_PARTIAL_CLOSE`.
905
+ :param targets: Live position rows the dispatch should close.
906
+ For partial close this is the pre-existing rows (used by
907
+ the hook to derive the pre-snapshot delta), for full close
908
+ this drives the DELETE loop.
909
+ :param hooks: Plugin callbacks.
910
+ :param audit_payload: Optional extras to merge into the
911
+ initial ``dispatch_submitted`` event payload.
912
+ """
913
+ if kind == KIND_FULL_CLOSE:
914
+ return await self._run_full_close(
915
+ coid=coid, intent=intent, targets=targets,
916
+ hooks=hooks, audit_payload=audit_payload,
917
+ )
918
+ if kind == KIND_PARTIAL_CLOSE:
919
+ return await self._run_partial_close(
920
+ coid=coid, intent=intent,
921
+ hooks=hooks, audit_payload=audit_payload,
922
+ )
923
+ raise ValueError(
924
+ f"DispatchJournal.run_close: kind must be one of "
925
+ f"{{KIND_FULL_CLOSE, KIND_PARTIAL_CLOSE}}, got {kind!r}"
926
+ )
927
+
928
+ async def _run_full_close(
929
+ self,
930
+ *,
931
+ coid: str,
932
+ intent: CloseIntent,
933
+ targets: list['OrderRow'],
934
+ hooks: CloseDispatchHooks,
935
+ audit_payload: dict | None,
936
+ ) -> ExchangeOrder:
937
+ # (1) PERSIST command row + audit event.
938
+ target_ids = [r.exchange_order_id for r in targets]
939
+ create_close_target_row(
940
+ self.store,
941
+ coid=coid,
942
+ symbol=intent.symbol,
943
+ side=intent.side,
944
+ qty=intent.qty,
945
+ intent_key=intent.intent_key,
946
+ kind=KIND_FULL_CLOSE,
947
+ extra_payload={'targets': list(target_ids)},
948
+ )
949
+ submit_payload: dict[str, Any] = {
950
+ 'kind': KIND_FULL_CLOSE,
951
+ 'targets': target_ids,
952
+ }
953
+ if audit_payload:
954
+ submit_payload.update(audit_payload)
955
+ self.store.log_event(
956
+ 'dispatch_submitted',
957
+ client_order_id=coid,
958
+ intent_key=intent.intent_key,
959
+ payload=submit_payload,
960
+ )
961
+
962
+ # (2) SUBMIT — per-target DELETE chain inside the hook.
963
+ try:
964
+ outcome = await hooks.submit_full_close(
965
+ coid=coid, intent=intent, targets=targets,
966
+ )
967
+ except OrderDispositionUnknownError as exc:
968
+ mark_disposition_unknown(self.store, coid=coid)
969
+ self.store.log_event(
970
+ 'disposition_unknown',
971
+ client_order_id=coid,
972
+ intent_key=intent.intent_key,
973
+ payload={'phase': 'full_close_delete', 'reason': str(exc)},
974
+ )
975
+ raise
976
+ except ExchangeOrderRejectedError as exc:
977
+ mark_rejected(self.store, coid=coid)
978
+ self.store.log_event(
979
+ 'rejected',
980
+ client_order_id=coid,
981
+ intent_key=intent.intent_key,
982
+ payload={'phase': 'full_close_delete', 'reason': str(exc)},
983
+ )
984
+ raise
985
+
986
+ # (3) PERSIST closing state + targets.
987
+ mark_closing(
988
+ self.store,
989
+ coid=coid,
990
+ kind=KIND_FULL_CLOSE,
991
+ targets=outcome.applied_targets,
992
+ )
993
+ self.store.log_event(
994
+ 'close_dispatched',
995
+ client_order_id=coid,
996
+ intent_key=intent.intent_key,
997
+ payload={'mode': 'full', 'applied_targets': outcome.applied_targets},
998
+ )
999
+
1000
+ # (4) Return the synthetic ExchangeOrder built by the hook.
1001
+ row = self.store.get_order(coid)
1002
+ if row is None:
1003
+ raise RuntimeError(
1004
+ f"DispatchJournal._run_full_close: row vanished after closing "
1005
+ f"(coid={coid!r})"
1006
+ )
1007
+ return hooks.exchange_order_from_state(
1008
+ row=row, intent=intent, outcome=outcome,
1009
+ )
1010
+
1011
+ async def _run_partial_close(
1012
+ self,
1013
+ *,
1014
+ coid: str,
1015
+ intent: CloseIntent,
1016
+ hooks: CloseDispatchHooks,
1017
+ audit_payload: dict | None,
1018
+ ) -> ExchangeOrder:
1019
+ # (1) PERSIST command row + audit event.
1020
+ create_close_target_row(
1021
+ self.store,
1022
+ coid=coid,
1023
+ symbol=intent.symbol,
1024
+ side=intent.side,
1025
+ qty=intent.qty,
1026
+ intent_key=intent.intent_key,
1027
+ kind=KIND_PARTIAL_CLOSE,
1028
+ )
1029
+ submit_payload: dict[str, Any] = {'kind': KIND_PARTIAL_CLOSE}
1030
+ if audit_payload:
1031
+ submit_payload.update(audit_payload)
1032
+ self.store.log_event(
1033
+ 'dispatch_submitted',
1034
+ client_order_id=coid,
1035
+ intent_key=intent.intent_key,
1036
+ payload=submit_payload,
1037
+ )
1038
+
1039
+ # (2) SUBMIT — opposite-direction POST + race detection inside the hook.
1040
+ try:
1041
+ outcome = await hooks.submit_partial_close(
1042
+ coid=coid, intent=intent,
1043
+ )
1044
+ except OrderDispositionUnknownError as exc:
1045
+ mark_disposition_unknown(self.store, coid=coid)
1046
+ self.store.log_event(
1047
+ 'disposition_unknown',
1048
+ client_order_id=coid,
1049
+ intent_key=intent.intent_key,
1050
+ payload={'phase': 'partial_close_post', 'reason': str(exc)},
1051
+ )
1052
+ raise
1053
+ except ExchangeOrderRejectedError as exc:
1054
+ mark_rejected(self.store, coid=coid)
1055
+ self.store.log_event(
1056
+ 'rejected',
1057
+ client_order_id=coid,
1058
+ intent_key=intent.intent_key,
1059
+ payload={'phase': 'partial_close_post', 'reason': str(exc)},
1060
+ )
1061
+ raise
1062
+
1063
+ # (3) PERSIST server ref (if any).
1064
+ if outcome.deal_reference is not None:
1065
+ record_close_server_ref(
1066
+ self.store,
1067
+ coid=coid,
1068
+ deal_reference=outcome.deal_reference,
1069
+ kind=KIND_PARTIAL_CLOSE,
1070
+ )
1071
+ self.store.log_event(
1072
+ 'deal_reference_seen',
1073
+ client_order_id=coid,
1074
+ payload={'deal_reference': outcome.deal_reference},
1075
+ )
1076
+
1077
+ # (4) PERSIST completion. The helper only advances state;
1078
+ # ``close_order`` is issued *after* the ``confirmed`` event so
1079
+ # the audit order is consistent.
1080
+ mark_close_completed(
1081
+ self.store,
1082
+ coid=coid,
1083
+ kind=KIND_PARTIAL_CLOSE,
1084
+ )
1085
+ self.store.log_event(
1086
+ 'confirmed',
1087
+ client_order_id=coid,
1088
+ exchange_order_id=outcome.exchange_id,
1089
+ intent_key=intent.intent_key,
1090
+ payload={
1091
+ 'mode': 'partial',
1092
+ 'applied_targets': outcome.applied_targets,
1093
+ 'fill_price': outcome.fill_price,
1094
+ },
1095
+ )
1096
+ self.store.close_order(coid)
1097
+
1098
+ # (5) Return the ExchangeOrder built by the hook.
1099
+ row = self.store.get_order(coid)
1100
+ if row is None:
1101
+ raise RuntimeError(
1102
+ f"DispatchJournal._run_partial_close: row vanished after confirm "
1103
+ f"(coid={coid!r})"
1104
+ )
1105
+ return hooks.exchange_order_from_state(
1106
+ row=row, intent=intent, outcome=outcome,
1107
+ )
1108
+
1109
+ # --- Cancel path -------------------------------------------------------
1110
+
1111
+ async def run_cancel(
1112
+ self,
1113
+ *,
1114
+ coid: str,
1115
+ intent: CancelIntent,
1116
+ targets: list['OrderRow'],
1117
+ hooks: CancelDispatchHooks,
1118
+ audit_payload: dict | None = None,
1119
+ ) -> ExchangeOrder:
1120
+ """Run a cancel dispatch.
1121
+
1122
+ The journal persists the command row, calls the hook for the
1123
+ per-target sweep, and finalises the row with the
1124
+ ``reason_path`` from the outcome. The per-target REST calls
1125
+ and target-row mutations are owned by the hook.
1126
+
1127
+ :param coid: Cancel dispatch COID.
1128
+ :param intent: The :class:`CancelIntent` being dispatched.
1129
+ :param targets: Live rows the dispatch should cancel.
1130
+ :param hooks: Plugin callbacks.
1131
+ :param audit_payload: Optional extras for the
1132
+ ``dispatch_submitted`` event payload.
1133
+ """
1134
+ target_coids = [r.client_order_id for r in targets]
1135
+ agg_qty = sum(max(0.0, r.qty - r.filled_qty) for r in targets)
1136
+ primary_side = targets[0].side if targets else 'buy'
1137
+
1138
+ # (1) PERSIST command row + audit event.
1139
+ create_cancel_command_row(
1140
+ self.store,
1141
+ coid=coid,
1142
+ symbol=intent.symbol,
1143
+ side=primary_side,
1144
+ qty=agg_qty,
1145
+ intent_key=intent.intent_key,
1146
+ pine_entry_id=intent.pine_id,
1147
+ from_entry=intent.from_entry,
1148
+ target_coids=target_coids,
1149
+ )
1150
+ submit_payload: dict[str, Any] = {
1151
+ 'kind': KIND_CANCEL,
1152
+ 'target_coids': target_coids,
1153
+ }
1154
+ if audit_payload:
1155
+ submit_payload.update(audit_payload)
1156
+ self.store.log_event(
1157
+ 'dispatch_submitted',
1158
+ client_order_id=coid,
1159
+ intent_key=intent.intent_key,
1160
+ payload=submit_payload,
1161
+ )
1162
+
1163
+ # (2) SUBMIT — per-target sweep inside the hook.
1164
+ try:
1165
+ outcome = await hooks.submit_cancel(
1166
+ coid=coid, intent=intent, targets=targets,
1167
+ )
1168
+ except OrderDispositionUnknownError as exc:
1169
+ mark_disposition_unknown(self.store, coid=coid)
1170
+ self.store.log_event(
1171
+ 'disposition_unknown',
1172
+ client_order_id=coid,
1173
+ intent_key=intent.intent_key,
1174
+ payload={'phase': 'cancel_sweep', 'reason': str(exc)},
1175
+ )
1176
+ raise
1177
+ except ExchangeOrderRejectedError as exc:
1178
+ mark_rejected(self.store, coid=coid)
1179
+ self.store.log_event(
1180
+ 'rejected',
1181
+ client_order_id=coid,
1182
+ intent_key=intent.intent_key,
1183
+ payload={'phase': 'cancel_sweep', 'reason': str(exc)},
1184
+ )
1185
+ raise
1186
+
1187
+ # (3) PERSIST completion with reason_path.
1188
+ mark_cancel_completed(
1189
+ self.store,
1190
+ coid=coid,
1191
+ reason_path=outcome.reason_path,
1192
+ extra_payload={
1193
+ 'applied_target_coids': outcome.applied_target_coids,
1194
+ },
1195
+ )
1196
+ self.store.log_event(
1197
+ 'confirmed',
1198
+ client_order_id=coid,
1199
+ intent_key=intent.intent_key,
1200
+ payload={
1201
+ 'reason_path': outcome.reason_path,
1202
+ 'cleared_legs': outcome.cleared_legs,
1203
+ 'applied_target_coids': outcome.applied_target_coids,
1204
+ },
1205
+ )
1206
+ self.store.close_order(coid)
1207
+
1208
+ # (4) Return the synthetic ExchangeOrder built by the hook.
1209
+ row = self.store.get_order(coid)
1210
+ if row is None:
1211
+ raise RuntimeError(
1212
+ f"DispatchJournal.run_cancel: row vanished after confirm "
1213
+ f"(coid={coid!r})"
1214
+ )
1215
+ return hooks.exchange_order_from_state(
1216
+ row=row, intent=intent, outcome=outcome,
1217
+ )
1218
+
1219
+ # --- Modify entry path -------------------------------------------------
1220
+
1221
+ async def run_modify_entry(
1222
+ self,
1223
+ *,
1224
+ coid: str,
1225
+ target_coid: str,
1226
+ old_intent: EntryIntent,
1227
+ new_intent: EntryIntent,
1228
+ qty: float,
1229
+ hooks: ModifyEntryDispatchHooks,
1230
+ audit_payload: dict | None = None,
1231
+ ) -> list[ExchangeOrder]:
1232
+ """Run an atomic working-order amend dispatch.
1233
+
1234
+ :param coid: COID of the amend command row.
1235
+ :param target_coid: COID of the working order being amended.
1236
+ :param old_intent: Intent before the amend (for audit context).
1237
+ :param new_intent: Intent the broker should land.
1238
+ :param qty: Order quantity (unchanged across the amend).
1239
+ :param hooks: Plugin callbacks.
1240
+ :param audit_payload: Optional extras for the
1241
+ ``dispatch_submitted`` event payload.
1242
+ """
1243
+ # (1) PERSIST command row + audit event.
1244
+ new_level = float(new_intent.limit if new_intent.limit is not None
1245
+ else new_intent.stop or 0.0)
1246
+ create_modify_entry_row(
1247
+ self.store,
1248
+ coid=coid,
1249
+ target_coid=target_coid,
1250
+ symbol=new_intent.symbol,
1251
+ side=new_intent.side,
1252
+ qty=qty,
1253
+ intent_key=new_intent.intent_key,
1254
+ new_level=new_level,
1255
+ pine_entry_id=new_intent.pine_id,
1256
+ )
1257
+ submit_payload: dict[str, Any] = {
1258
+ 'kind': KIND_MODIFY_ENTRY,
1259
+ 'target_coid': target_coid,
1260
+ 'new_level': new_level,
1261
+ }
1262
+ if audit_payload:
1263
+ submit_payload.update(audit_payload)
1264
+ self.store.log_event(
1265
+ 'dispatch_submitted',
1266
+ client_order_id=coid,
1267
+ intent_key=new_intent.intent_key,
1268
+ payload=submit_payload,
1269
+ )
1270
+
1271
+ # (2) SUBMIT — PUT + confirm inside the hook.
1272
+ try:
1273
+ outcome = await hooks.submit_amend(
1274
+ coid=coid, target_coid=target_coid,
1275
+ old_intent=old_intent, new_intent=new_intent,
1276
+ )
1277
+ except OrderDispositionUnknownError as exc:
1278
+ mark_disposition_unknown(self.store, coid=coid)
1279
+ self.store.log_event(
1280
+ 'disposition_unknown',
1281
+ client_order_id=coid,
1282
+ intent_key=new_intent.intent_key,
1283
+ payload={'phase': 'modify_entry_put', 'reason': str(exc)},
1284
+ )
1285
+ raise
1286
+ except ExchangeOrderRejectedError as exc:
1287
+ mark_rejected(self.store, coid=coid)
1288
+ self.store.log_event(
1289
+ 'rejected',
1290
+ client_order_id=coid,
1291
+ intent_key=new_intent.intent_key,
1292
+ payload={'phase': 'modify_entry_put', 'reason': str(exc)},
1293
+ )
1294
+ raise
1295
+
1296
+ # (3) PERSIST server ref + completion.
1297
+ self.store.add_ref(coid, 'deal_reference', outcome.server_ref)
1298
+ self.store.log_event(
1299
+ 'deal_reference_seen',
1300
+ client_order_id=coid,
1301
+ payload={'deal_reference': outcome.server_ref},
1302
+ )
1303
+ mark_modify_completed(
1304
+ self.store,
1305
+ coid=coid,
1306
+ extra_payload={'echoed_level': outcome.new_level},
1307
+ )
1308
+ self.store.log_event(
1309
+ 'confirmed',
1310
+ client_order_id=coid,
1311
+ intent_key=new_intent.intent_key,
1312
+ payload={'new_level': outcome.new_level},
1313
+ )
1314
+ self.store.close_order(coid)
1315
+
1316
+ # (4) Return the engine-facing list.
1317
+ row = self.store.get_order(coid)
1318
+ if row is None:
1319
+ raise RuntimeError(
1320
+ f"DispatchJournal.run_modify_entry: row vanished after confirm "
1321
+ f"(coid={coid!r})"
1322
+ )
1323
+ return hooks.exchange_order_from_state(
1324
+ row=row, new_intent=new_intent, outcome=outcome,
1325
+ )
1326
+
1327
+ # --- Modify exit path --------------------------------------------------
1328
+
1329
+ async def run_modify_exit(
1330
+ self,
1331
+ *,
1332
+ coid: str,
1333
+ target_coid: str,
1334
+ target_row: 'OrderRow',
1335
+ old_intent: ExitIntent,
1336
+ new_intent: ExitIntent,
1337
+ qty: float,
1338
+ hooks: ModifyExitDispatchHooks,
1339
+ audit_payload: dict | None = None,
1340
+ ) -> list[ExchangeOrder]:
1341
+ """Run a position bracket amend dispatch.
1342
+
1343
+ The journal owns the entry-side command row state machine and
1344
+ invokes :meth:`mirror_bracket_legs` on the happy path. The
1345
+ synthetic leg rows themselves are written by the hook (both
1346
+ the disposition-unknown seeds in the ambiguous path and the
1347
+ confirmed leg rows in the mirror path).
1348
+
1349
+ :param coid: COID of the amend command row.
1350
+ :param target_coid: COID of the entry row representing the
1351
+ position being amended.
1352
+ :param target_row: Live row of the target entry (passed to the
1353
+ mirror callback).
1354
+ :param old_intent: Intent before the amend.
1355
+ :param new_intent: Intent the broker should land.
1356
+ :param qty: Position quantity (unchanged across the amend).
1357
+ :param hooks: Plugin callbacks.
1358
+ :param audit_payload: Optional extras for the
1359
+ ``dispatch_submitted`` event payload.
1360
+ """
1361
+ # (1) PERSIST command row + audit event.
1362
+ create_modify_exit_row(
1363
+ self.store,
1364
+ coid=coid,
1365
+ target_coid=target_coid,
1366
+ symbol=new_intent.symbol,
1367
+ side=new_intent.side,
1368
+ qty=qty,
1369
+ intent_key=new_intent.intent_key,
1370
+ new_tp=new_intent.tp_price,
1371
+ new_sl=new_intent.sl_price,
1372
+ new_trail=new_intent.trail_offset,
1373
+ new_trail_price=new_intent.trail_price,
1374
+ pine_entry_id=new_intent.pine_id,
1375
+ from_entry=new_intent.from_entry,
1376
+ )
1377
+ submit_payload: dict[str, Any] = {
1378
+ 'kind': KIND_MODIFY_EXIT,
1379
+ 'target_coid': target_coid,
1380
+ 'new_tp': new_intent.tp_price,
1381
+ 'new_sl': new_intent.sl_price,
1382
+ 'new_trail': new_intent.trail_offset,
1383
+ }
1384
+ if audit_payload:
1385
+ submit_payload.update(audit_payload)
1386
+ self.store.log_event(
1387
+ 'dispatch_submitted',
1388
+ client_order_id=coid,
1389
+ intent_key=new_intent.intent_key,
1390
+ payload=submit_payload,
1391
+ )
1392
+
1393
+ # (2) SUBMIT — PUT + confirm inside the hook; ambiguous-path
1394
+ # leg seeding is the hook's responsibility before re-raising.
1395
+ try:
1396
+ outcome = await hooks.submit_amend(
1397
+ coid=coid, target_coid=target_coid,
1398
+ old_intent=old_intent, new_intent=new_intent,
1399
+ )
1400
+ except OrderDispositionUnknownError as exc:
1401
+ mark_disposition_unknown(self.store, coid=coid)
1402
+ self.store.log_event(
1403
+ 'disposition_unknown',
1404
+ client_order_id=coid,
1405
+ intent_key=new_intent.intent_key,
1406
+ payload={'phase': 'modify_exit_put', 'reason': str(exc)},
1407
+ )
1408
+ raise
1409
+ except ExchangeOrderRejectedError as exc:
1410
+ mark_rejected(self.store, coid=coid)
1411
+ self.store.log_event(
1412
+ 'rejected',
1413
+ client_order_id=coid,
1414
+ intent_key=new_intent.intent_key,
1415
+ payload={'phase': 'modify_exit_put', 'reason': str(exc)},
1416
+ )
1417
+ raise
1418
+
1419
+ # (3) PERSIST server ref.
1420
+ self.store.add_ref(coid, 'deal_reference', outcome.server_ref)
1421
+ self.store.log_event(
1422
+ 'deal_reference_seen',
1423
+ client_order_id=coid,
1424
+ payload={'deal_reference': outcome.server_ref},
1425
+ )
1426
+
1427
+ # (4) MIRROR bracket legs BEFORE marking the command row terminal.
1428
+ # The command row stays live (and recoverable) until the legs are
1429
+ # written, so a crash between the broker-accepted PUT and the
1430
+ # mirror callback leaves recovery able to re-run the verdict
1431
+ # (confirms/{ref} → ACCEPTED) and replay the mirror on next sync
1432
+ # via the snapshot reconciler. Once mark_modify_completed +
1433
+ # close_order run, the row leaves the live set and the leg state
1434
+ # must already be durable.
1435
+ hooks.mirror_bracket_legs(
1436
+ target_row=target_row, new_intent=new_intent, outcome=outcome,
1437
+ )
1438
+
1439
+ # (5) PERSIST terminal completion + close.
1440
+ mark_modify_completed(
1441
+ self.store,
1442
+ coid=coid,
1443
+ extra_payload={'post_put_state': dict(outcome.post_put_state)},
1444
+ )
1445
+ self.store.log_event(
1446
+ 'confirmed',
1447
+ client_order_id=coid,
1448
+ intent_key=new_intent.intent_key,
1449
+ payload={
1450
+ 'deal_status': outcome.deal_status,
1451
+ 'post_put_state': dict(outcome.post_put_state),
1452
+ },
1453
+ )
1454
+ self.store.close_order(coid)
1455
+
1456
+ # (6) Return the engine-facing list.
1457
+ row = self.store.get_order(coid)
1458
+ if row is None:
1459
+ raise RuntimeError(
1460
+ f"DispatchJournal.run_modify_exit: row vanished after confirm "
1461
+ f"(coid={coid!r})"
1462
+ )
1463
+ return hooks.exchange_order_from_state(
1464
+ row=row, new_intent=new_intent, outcome=outcome,
1465
+ )
1466
+
1467
+ # --- Recovery path -----------------------------------------------------
1468
+
1469
+ async def recover_pending(
1470
+ self,
1471
+ hooks_for: 'PendingHooksProvider',
1472
+ ) -> list['PendingResolution']:
1473
+ """Replay every pending row through the plugin's resume hook.
1474
+
1475
+ Called once after :class:`~pynecore.core.broker.storage.BrokerStore.open_run`
1476
+ and before the first sync-engine iteration. The plugin
1477
+ supplies a callable that builds the per-row hook from the
1478
+ stored ``extras`` / refs; this keeps the journal free of
1479
+ plugin-specific construction logic.
1480
+
1481
+ :param hooks_for: Callable that returns either an
1482
+ :class:`EntryDispatchHooks` for an entry row, or
1483
+ ``None`` if the row's ``extras['kind']`` is not handled
1484
+ by the entry journal (other kinds — bracket legs etc. —
1485
+ land in their own journals later).
1486
+ :return: One :class:`PendingResolution` per processed row,
1487
+ useful for diagnostics / tests.
1488
+ """
1489
+ resolutions: list[PendingResolution] = []
1490
+ for row in find_pending_dispatch(self.store):
1491
+ hooks = hooks_for(row)
1492
+ if hooks is None:
1493
+ resolutions.append(PendingResolution(
1494
+ coid=row.client_order_id,
1495
+ status='skipped',
1496
+ reason='unhandled_kind',
1497
+ ))
1498
+ continue
1499
+ refs = _collect_refs_for(self.store, coid=row.client_order_id)
1500
+ outcome = await hooks.resume_pending_dispatch(row=row, refs=refs)
1501
+ resolutions.append(self._apply_resume_outcome(row, outcome))
1502
+ return resolutions
1503
+
1504
+ def _apply_resume_outcome(
1505
+ self,
1506
+ row: 'OrderRow',
1507
+ outcome: ResumeOutcome,
1508
+ ) -> 'PendingResolution':
1509
+ """Persist the recovery verdict and return a diagnostic record.
1510
+
1511
+ The ``confirmed`` verdict's terminal-state writer depends on the
1512
+ row's ``extras['kind']``: entry rows (``ENTRY_KIND_POSITION`` /
1513
+ ``ENTRY_KIND_WORKING``) stay live as the engine-facing order, so
1514
+ :func:`mark_confirmed_with_fill` is the right helper. Command
1515
+ rows are one-shot dispatch records — they need the matching
1516
+ terminal-state writer plus, except for ``KIND_FULL_CLOSE``, an
1517
+ explicit :meth:`RunContext.close_order` to leave the live-orders
1518
+ set:
1519
+
1520
+ * ``KIND_MODIFY_ENTRY`` → :func:`mark_modify_completed`
1521
+ * ``KIND_MODIFY_EXIT`` → :func:`mark_modify_completed` (same
1522
+ one-shot semantics as the entry-amend; the bracket leg rows
1523
+ are reconciled separately by the plugin's snapshot resolver
1524
+ and are outside this row's scope).
1525
+ * ``KIND_CANCEL`` → :func:`mark_cancel_completed`
1526
+ (``reason_path='recovered'``)
1527
+ * ``KIND_FULL_CLOSE`` → :func:`mark_closing` (the row stays
1528
+ live in ``closing`` state until the activity stream promotes
1529
+ each target to ``closed``).
1530
+ * ``KIND_PARTIAL_CLOSE`` → :func:`mark_close_completed`
1531
+ """
1532
+ kind = (row.extras or {}).get('kind')
1533
+ if outcome.status == 'confirmed':
1534
+ if kind == KIND_MODIFY_ENTRY:
1535
+ mark_modify_completed(
1536
+ self.store,
1537
+ coid=row.client_order_id,
1538
+ extra_payload=(
1539
+ {'recovery_path': outcome.recovery_path}
1540
+ if outcome.recovery_path is not None else None
1541
+ ),
1542
+ )
1543
+ self.store.close_order(row.client_order_id)
1544
+ elif kind == KIND_MODIFY_EXIT:
1545
+ extras_payload: dict[str, Any] = {}
1546
+ if outcome.recovery_path is not None:
1547
+ extras_payload['recovery_path'] = outcome.recovery_path
1548
+ if outcome.recovery_context is not None:
1549
+ extras_payload['recovery_context'] = dict(
1550
+ outcome.recovery_context
1551
+ )
1552
+ mark_modify_completed(
1553
+ self.store,
1554
+ coid=row.client_order_id,
1555
+ extra_payload=extras_payload or None,
1556
+ )
1557
+ self.store.close_order(row.client_order_id)
1558
+ elif kind == KIND_CANCEL:
1559
+ extra: dict[str, Any] = {}
1560
+ if outcome.recovery_path is not None:
1561
+ extra['recovery_path'] = outcome.recovery_path
1562
+ applied: list[str] | None = (outcome.recovery_context or {}).get(
1563
+ 'applied_target_coids'
1564
+ ) if outcome.recovery_context is not None else None
1565
+ if applied is not None:
1566
+ extra['applied_target_coids'] = list(applied)
1567
+ mark_cancel_completed(
1568
+ self.store,
1569
+ coid=row.client_order_id,
1570
+ reason_path='recovered',
1571
+ extra_payload=extra or None,
1572
+ )
1573
+ self.store.close_order(row.client_order_id)
1574
+ elif kind == KIND_FULL_CLOSE:
1575
+ # Recovery verdict says every DELETE landed (targets
1576
+ # vanished from the broker snapshot). Promote the command
1577
+ # row to ``closing`` — matching the live dispatch end
1578
+ # state — and leave it open; the activity stream then
1579
+ # closes each target row in due course.
1580
+ ctx = outcome.recovery_context or {}
1581
+ applied_targets = list(ctx.get('applied_targets') or [])
1582
+ if not applied_targets:
1583
+ applied_targets = list((row.extras or {}).get('targets') or [])
1584
+ extra_full: dict[str, Any] = {}
1585
+ if outcome.recovery_path is not None:
1586
+ extra_full['recovery_path'] = outcome.recovery_path
1587
+ mark_closing(
1588
+ self.store,
1589
+ coid=row.client_order_id,
1590
+ kind=KIND_FULL_CLOSE,
1591
+ targets=applied_targets,
1592
+ extra_payload=extra_full or None,
1593
+ )
1594
+ elif kind == KIND_PARTIAL_CLOSE:
1595
+ extra_partial: dict[str, Any] = {}
1596
+ if outcome.recovery_path is not None:
1597
+ extra_partial['recovery_path'] = outcome.recovery_path
1598
+ mark_close_completed(
1599
+ self.store,
1600
+ coid=row.client_order_id,
1601
+ kind=KIND_PARTIAL_CLOSE,
1602
+ extra_payload=extra_partial or None,
1603
+ )
1604
+ self.store.close_order(row.client_order_id)
1605
+ else:
1606
+ mark_confirmed_with_fill(
1607
+ self.store,
1608
+ coid=row.client_order_id,
1609
+ exchange_id=outcome.exchange_id,
1610
+ is_filled=outcome.is_filled,
1611
+ filled_qty=outcome.filled_qty,
1612
+ fill_price=outcome.fill_price,
1613
+ )
1614
+ payload: dict[str, Any] = {
1615
+ 'is_filled': outcome.is_filled,
1616
+ 'fill_price': outcome.fill_price,
1617
+ 'prior_state': row.state,
1618
+ }
1619
+ _merge_recovery_diagnostics(payload, outcome)
1620
+ self.store.log_event(
1621
+ 'recovered_confirmed',
1622
+ client_order_id=row.client_order_id,
1623
+ exchange_order_id=outcome.exchange_id,
1624
+ intent_key=row.intent_key,
1625
+ payload=payload,
1626
+ )
1627
+ elif outcome.status == 'rejected':
1628
+ mark_rejected(self.store, coid=row.client_order_id)
1629
+ payload = {
1630
+ 'reason': outcome.reject_reason,
1631
+ 'prior_state': row.state,
1632
+ }
1633
+ _merge_recovery_diagnostics(payload, outcome)
1634
+ self.store.log_event(
1635
+ 'recovered_rejected',
1636
+ client_order_id=row.client_order_id,
1637
+ intent_key=row.intent_key,
1638
+ payload=payload,
1639
+ )
1640
+ else:
1641
+ # still_unknown — keep the row; engine reconciler retries.
1642
+ payload = {'prior_state': row.state}
1643
+ _merge_recovery_diagnostics(payload, outcome)
1644
+ self.store.log_event(
1645
+ 'recovery_pending',
1646
+ client_order_id=row.client_order_id,
1647
+ intent_key=row.intent_key,
1648
+ payload=payload,
1649
+ )
1650
+ return PendingResolution(
1651
+ coid=row.client_order_id,
1652
+ status=outcome.status,
1653
+ reason=outcome.reject_reason,
1654
+ )
1655
+
1656
+ def apply_reconcile_outcome(
1657
+ self,
1658
+ coid: str,
1659
+ outcome: ReconcileOutcome,
1660
+ ) -> None:
1661
+ """Persist a reconcile-path terminal mutation.
1662
+
1663
+ Called once per row that the plugin reconciler decides needs a
1664
+ terminal lifecycle write — working→position fill detection,
1665
+ bracket sibling retire on mixed-bracket rejection, pending-trail
1666
+ parent-rejection cascade, missing-pending grace expiry,
1667
+ unexpected-cancel cascade, eager-teardown follow-up. The plugin
1668
+ decides *what happened* (sibling selection, reason
1669
+ classification, snapshot interpretation); the journal applies
1670
+ the state transition, optionally retires the row from
1671
+ ``iter_live_orders``, and writes the audit event.
1672
+
1673
+ Cat 1 reconciler-private breadcrumbs
1674
+ (``missing_pending_since`` / ``close_event_yielded_at`` family)
1675
+ are NOT routed here — those are plugin-namespace
1676
+ ``extras`` and stay direct :meth:`RunContext.upsert_order`
1677
+ writes. See :class:`ReconcileOutcome` for the full ownership
1678
+ contract.
1679
+
1680
+ :param coid: The row's client-order-id.
1681
+ :param outcome: The plugin's declared reconcile verdict.
1682
+ :raises ValueError: when ``outcome.kind`` is ``'filled'`` but
1683
+ ``outcome.filled_qty`` is ``None``.
1684
+ """
1685
+ if outcome.kind == 'filled':
1686
+ if outcome.filled_qty is None:
1687
+ raise ValueError(
1688
+ f"ReconcileOutcome kind='filled' requires filled_qty, "
1689
+ f"got None (coid={coid!r}, reason={outcome.reason!r})"
1690
+ )
1691
+ mark_reconcile_filled(
1692
+ self.store,
1693
+ coid=coid,
1694
+ filled_qty=outcome.filled_qty,
1695
+ new_state=outcome.new_state,
1696
+ extras_patch=outcome.extras_patch,
1697
+ )
1698
+ else:
1699
+ mark_reconcile_terminal_close(
1700
+ self.store,
1701
+ coid=coid,
1702
+ new_state=outcome.new_state,
1703
+ extras_patch=outcome.extras_patch,
1704
+ close_row=outcome.close_row,
1705
+ )
1706
+ self.store.log_event(
1707
+ outcome.audit_event,
1708
+ client_order_id=coid,
1709
+ exchange_order_id=outcome.exchange_order_id,
1710
+ payload=(
1711
+ dict(outcome.audit_payload)
1712
+ if outcome.audit_payload is not None else None
1713
+ ),
1714
+ )
1715
+
1716
+
1717
+ # === Recovery support types ================================================
1718
+
1719
+ @dataclass(frozen=True)
1720
+ class PendingResolution:
1721
+ """One row's recovery outcome, returned for inspection / tests.
1722
+
1723
+ :ivar coid: The recovered row's client-order-id.
1724
+ :ivar status: ``'confirmed'`` / ``'rejected'`` / ``'still_unknown'``
1725
+ (echo of :class:`ResumeOutcome.status`) or ``'skipped'`` when
1726
+ the row's ``kind`` was not handled by this journal.
1727
+ :ivar reason: Free-form note. For ``'rejected'`` carries the
1728
+ plugin's reject reason; for ``'skipped'`` describes why the
1729
+ row was bypassed.
1730
+ """
1731
+ coid: str
1732
+ status: Literal['confirmed', 'rejected', 'still_unknown', 'skipped']
1733
+ reason: str | None = None
1734
+
1735
+
1736
+ class PendingHooksProvider(Protocol):
1737
+ """Callable that maps a pending row to its recovery hook.
1738
+
1739
+ The plugin implements this to decide which hook flavour applies
1740
+ to each ``extras['kind']``. Returning ``None`` makes the journal
1741
+ record a ``skipped`` resolution for that row (e.g. bracket legs
1742
+ handled by their own resolver).
1743
+
1744
+ The recovery contract only requires ``resume_pending_dispatch``;
1745
+ the duck-typed return is annotated as :class:`EntryDispatchHooks`
1746
+ for IDE convenience, but any hook flavour with a compatible
1747
+ ``resume_pending_dispatch`` shape is accepted.
1748
+ """
1749
+
1750
+ def __call__(self, row: 'OrderRow') -> EntryDispatchHooks | None: ...
1751
+
1752
+
1753
+ # === Private helpers =======================================================
1754
+
1755
+ def _merge_recovery_diagnostics(
1756
+ payload: dict[str, Any], outcome: ResumeOutcome,
1757
+ ) -> None:
1758
+ """Inject ``recovery_path`` / ``recovery_context`` into an event payload.
1759
+
1760
+ Both fields are optional plugin-supplied diagnostics; when ``None``
1761
+ they are not written at all so the on-disk audit shape stays
1762
+ minimal for hooks that do not bother annotating the route.
1763
+ """
1764
+ if outcome.recovery_path is not None:
1765
+ payload['recovery_path'] = outcome.recovery_path
1766
+ if outcome.recovery_context is not None:
1767
+ payload['recovery_context'] = dict(outcome.recovery_context)
1768
+
1769
+
1770
+ def _collect_refs_for(store: 'RunContext', *, coid: str) -> Mapping[str, str]:
1771
+ """Materialise the full ``order_refs`` map for one COID.
1772
+
1773
+ Required for the narrow crash window inside
1774
+ :func:`~pynecore.core.broker.store_helpers.record_server_ref`:
1775
+ between the ``add_ref('deal_reference', ...)`` commit and the
1776
+ subsequent ``upsert_order(extras={...})`` commit the
1777
+ ``deal_reference`` lives *only* in ``order_refs``. A resume hook
1778
+ that relied solely on ``row.extras`` would miss it and treat the
1779
+ already-submitted order as never posted, causing a duplicate POST
1780
+ on the next restart.
1781
+
1782
+ The result is a plain ``dict``; multiple refs of the same type are
1783
+ not expected per COID and the last one wins if they ever occur.
1784
+ """
1785
+ return dict(store.iter_refs_for_coid(coid))