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,1128 @@
1
+ """One-way position emulation engine over a hedging-mode broker account.
2
+
3
+ A hedging account holds several open positions ("legs") per symbol; a Pine
4
+ strategy sees a single *one-way* position. The pure helpers in
5
+ :mod:`~pynecore.core.broker.emulator` decide *what* to do (net the legs, pick a
6
+ FIFO close plan, snap per-leg volumes); this engine *drives* it: it fans a
7
+ :class:`~pynecore.core.broker.models.CloseIntent` out across the legs through a
8
+ thin :class:`~pynecore.core.plugin.broker.PositionPort`, and owns the
9
+ persist-first ledger that lets an interrupted fan-out resume on restart without
10
+ re-closing a leg that already settled.
11
+
12
+ It is the close/reversal counterpart of
13
+ :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
14
+ and :class:`~pynecore.core.broker.software_partial_bracket_engine.SoftwarePartialBracketEngine`:
15
+ one instance per :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine`, the
16
+ persisted close-leg rows in :mod:`store_helpers` are the durable representation
17
+ (PERSIST-FIRST), and :meth:`restart_replay` re-derives an unfinished fan-out
18
+ from them. The legs' close FILLs themselves flow through the engine's ordinary
19
+ natural-close path (mapped to ``LegType.CLOSE`` by the plugin), so this engine
20
+ never touches the fill router — only the dispatch + its crash/replay.
21
+
22
+ The engine carries no broker, clock, or exchange-model state. Each broker action
23
+ goes through the :class:`PositionPort`; aggregation / selection / quantization
24
+ are the pure functions in :mod:`emulator`. That keeps it deterministic and
25
+ unit-testable in isolation, with ``store_ctx=None`` for the no-persistence path.
26
+ """
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING
29
+
30
+ from pynecore.core.broker.emulator import (
31
+ LegClose,
32
+ aggregate_positions,
33
+ net_survivor_legs,
34
+ plan_leg_close_volumes,
35
+ plan_reversal,
36
+ select_legs_for_close,
37
+ )
38
+ from pynecore.core.broker.exceptions import (
39
+ BracketAttachAfterFillRejectedError,
40
+ ExchangeConnectionError,
41
+ ExchangeOrderRejectedError,
42
+ OrderDispositionUnknownError,
43
+ OrderSkippedByPlugin,
44
+ )
45
+ from pynecore.core.broker.idempotency import (
46
+ CLIENT_ORDER_ID_MAX_LEN,
47
+ KIND_CANCEL,
48
+ KIND_CLOSE,
49
+ KIND_ENTRY,
50
+ KIND_ENTRY_STOP,
51
+ KIND_EXIT_SL,
52
+ parse_client_order_id,
53
+ parse_wire_client_order_id,
54
+ )
55
+ from pynecore.core.broker.models import (
56
+ CancelIntent,
57
+ CloseIntent,
58
+ DispatchEnvelope,
59
+ EntryIntent,
60
+ ExitIntent,
61
+ OrderType,
62
+ PositionLeg,
63
+ )
64
+ from pynecore.lib.log import broker_warning as _blog_warning
65
+ from pynecore.core.broker.store_helpers import (
66
+ BRACKET_OWN_STATE_CLEARING,
67
+ BRACKET_OWN_STATE_RELEASED,
68
+ CLOSE_LEG_STATE_DISPATCHED,
69
+ EXTRAS_KEY_BRACKET_OWN_ATTACH_COID,
70
+ EXTRAS_KEY_BRACKET_OWN_CLEAR_COID,
71
+ EXTRAS_KEY_BRACKET_OWN_LEG_ID,
72
+ EXTRAS_KEY_BRACKET_OWN_SL,
73
+ EXTRAS_KEY_BRACKET_OWN_STATE,
74
+ EXTRAS_KEY_BRACKET_OWN_TP,
75
+ EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET,
76
+ EXTRAS_KEY_CLOSE_LEG_ID,
77
+ EXTRAS_KEY_CLOSE_LEG_VOLUME,
78
+ EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS,
79
+ EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID,
80
+ EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ,
81
+ EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG,
82
+ clear_residual_open_row,
83
+ create_bracket_ownership_row,
84
+ create_close_leg_row,
85
+ create_residual_open_row,
86
+ iter_active_bracket_ownerships,
87
+ iter_active_close_legs,
88
+ iter_active_residual_opens,
89
+ update_bracket_ownership_state,
90
+ update_close_leg_state,
91
+ )
92
+
93
+ if TYPE_CHECKING:
94
+ from collections.abc import Callable
95
+
96
+ from pynecore.core.broker.storage import RunContext
97
+ from pynecore.core.plugin.broker import PositionPort
98
+
99
+
100
+ __all__ = [
101
+ 'CloseFanResult',
102
+ 'ReversalFanResult',
103
+ 'BracketFanResult',
104
+ 'ClearFanResult',
105
+ 'OneWayEmulator',
106
+ ]
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class CloseFanResult:
111
+ """Outcome of one fanned-out close, for the caller's return shaping / audit.
112
+
113
+ :ivar legs: ``(leg_id, broker-grid volume)`` pairs actually dispatched, in
114
+ FIFO order. Empty when nothing was open or the close was skipped.
115
+ :ivar shortfall: Pine-unit quantity the open legs could not cover (the
116
+ broker holds less than Pine believes). ``0.0`` in the normal case; a
117
+ positive value is the caller's signal to log, not halt.
118
+ :ivar skipped: ``True`` when the whole close quantized below the broker's
119
+ volume grid, so no order was sent (a non-halting skip — the engine
120
+ re-evaluates next bar). Distinct from an empty ``legs`` with
121
+ ``skipped=False``, which means the symbol was already flat.
122
+ """
123
+ legs: tuple[tuple[str, int], ...]
124
+ shortfall: float
125
+ skipped: bool
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ReversalFanResult:
130
+ """Outcome of one decomposed reversal / add, for the caller's return shaping.
131
+
132
+ :ivar closes: ``(leg_id, broker-grid volume)`` pairs FIFO-closed to retire
133
+ the opposing exposure. Empty for a pure add (no opposing legs).
134
+ :ivar open_qty: Pine-unit size opened in the order's own direction after the
135
+ closes. ``0.0`` for an exact flatten (close all, open nothing).
136
+ :ivar opened_orders: The ``ExchangeOrder``(s) the residual open produced.
137
+ Empty when ``open_qty`` was zero.
138
+ """
139
+ closes: tuple[tuple[str, int], ...]
140
+ open_qty: float
141
+ opened_orders: tuple
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class BracketFanResult:
146
+ """Outcome of one exit-bracket replication across a position's legs.
147
+
148
+ :ivar legs: Broker leg ids the bracket was replicated onto, in FIFO order.
149
+ :ivar skipped: ``True`` when the symbol was flat (no position-side legs to
150
+ protect) — a non-halting no-op; the caller re-evaluates next bar.
151
+ """
152
+ legs: tuple[str, ...]
153
+ skipped: bool
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class ClearFanResult:
158
+ """Outcome of one ownership-scoped exit-bracket clear.
159
+
160
+ :ivar legs: Broker leg ids actually cleared — the subset the cancelled exit
161
+ owns, never the whole position side. Empty when the exit owned nothing
162
+ (already released, or no persisted ownership index).
163
+ """
164
+ legs: tuple[str, ...]
165
+
166
+
167
+ class OneWayEmulator:
168
+ """Drives Pine one-way close/reversal over a hedging account's legs.
169
+
170
+ One instance per :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine`.
171
+ The engine owns the persisted close-leg ledger and its restart replay; every
172
+ broker-side action is performed through the :class:`PositionPort` the
173
+ emulating plugin exposes.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ store_ctx: 'RunContext | None',
179
+ *,
180
+ mintick: float = 1.0,
181
+ block_exposure_reopens: 'Callable[[], bool] | None' = None,
182
+ ) -> None:
183
+ self._store_ctx = store_ctx
184
+ self._mintick = mintick
185
+ #: Engine-supplied gate consulted before a residual-open re-dispatch
186
+ #: (the reversal reopen leg). Wired to the engine's quarantine latch:
187
+ #: under quarantine the owed FIFO closes still run (risk-reducing)
188
+ #: but the reopen is withheld and its breadcrumb stays live, so the
189
+ #: committed reversal finishes after the operator restart.
190
+ self._block_exposure_reopens = block_exposure_reopens
191
+
192
+ def _trail_price_distance(self, trail_offset: float | None) -> float | None:
193
+ """Convert Pine trailing-offset ticks to the PositionPort price unit."""
194
+ if trail_offset is None:
195
+ return None
196
+ return trail_offset * self._mintick
197
+
198
+ # === Close fan-out ====================================================
199
+
200
+ async def run_close(
201
+ self, envelope: 'DispatchEnvelope', port: 'PositionPort',
202
+ ) -> CloseFanResult:
203
+ """Fan a :class:`CloseIntent` out as per-leg FIFO closes.
204
+
205
+ Reads the live legs, nets them, selects the oldest legs on the position
206
+ side to cover ``intent.qty``, snaps the plan to the broker grid, and
207
+ dispatches one :meth:`PositionPort.close_leg` per leg — persisting each
208
+ leg PERSIST-FIRST so a crash mid-fan resumes via :meth:`restart_replay`.
209
+
210
+ Never raises for an undersized close: a whole-close-below-grid returns
211
+ ``skipped=True`` and a broker-holds-less returns a positive
212
+ ``shortfall`` — the bot keeps running and the caller decides how to
213
+ surface it.
214
+ """
215
+ intent = envelope.intent
216
+ assert isinstance(intent, CloseIntent)
217
+ legs = await port.fetch_raw_positions(intent.symbol)
218
+ pos = aggregate_positions(intent.symbol, legs)
219
+ if pos is None or pos.side == 'flat':
220
+ # Nothing open, or offsetting legs net to flat — benign no-op.
221
+ return CloseFanResult(legs=(), shortfall=0.0, skipped=False)
222
+ # CloseIntent.side is the close direction ("sell" closes a long); the
223
+ # legs to reduce sit on the opposite side of the book.
224
+ leg_side = 'buy' if intent.side == 'sell' else 'sell'
225
+ closes, shortfall = select_legs_for_close(intent.qty, legs, leg_side)
226
+ if not closes:
227
+ return CloseFanResult(legs=(), shortfall=shortfall, skipped=False)
228
+ await self._reject_unsupported_partial_closes(
229
+ closes, legs, symbol=intent.symbol,
230
+ intent_key=intent.intent_key, port=port,
231
+ )
232
+ parent_coid = envelope.client_order_id(KIND_CLOSE)
233
+ dispatched = await self._fan_out_closes(
234
+ closes, symbol=intent.symbol, side=intent.side,
235
+ intent_key=intent.intent_key, pine_id=intent.pine_id,
236
+ parent_coid=parent_coid, port=port,
237
+ )
238
+ if not dispatched:
239
+ # Every slice rounded below the broker grid — skip, do not halt.
240
+ return CloseFanResult(legs=(), shortfall=shortfall, skipped=True)
241
+ return CloseFanResult(
242
+ legs=tuple(dispatched), shortfall=shortfall, skipped=False,
243
+ )
244
+
245
+ async def run_reversal(
246
+ self, envelope: 'DispatchEnvelope', port: 'PositionPort',
247
+ ) -> ReversalFanResult:
248
+ """Decompose a Pine reversal/add entry over the hedging legs.
249
+
250
+ Pine folds a reversal into one combined-size :class:`EntryIntent`
251
+ assuming a netting auto-flip. On a hedging account that single order
252
+ would open a separate opposing leg, so it is split: FIFO-close the
253
+ opposing legs (up to the order size) and open only the residual in the
254
+ order's own direction. Pure add (no opposing legs) opens the whole size;
255
+ exact flatten (opposing exposure == order size) closes all and opens
256
+ nothing.
257
+ """
258
+ intent = envelope.intent
259
+ assert isinstance(intent, EntryIntent)
260
+ if intent.order_type is not OrderType.MARKET:
261
+ # A resting LIMIT / STOP entry is not Pine's combined-size market
262
+ # auto-flip: it rests as a fresh working order and is decomposed
263
+ # (if it is still a reversal when it triggers) only at FILL time.
264
+ # Place it full-size now with NO opposing-leg close — retiring the
265
+ # opposing exposure here would flatten the position before the entry
266
+ # ever fires. (Fill-time decomposition of a resting reversal is a
267
+ # follow-up.) ``place_leg`` runs the broker's own volume-bounds
268
+ # pre-flight, so an out-of-range resting order still skips cleanly.
269
+ opened = tuple(await port.place_leg(envelope, intent.qty))
270
+ return ReversalFanResult(
271
+ closes=(), open_qty=intent.qty, opened_orders=opened,
272
+ )
273
+ legs = await port.fetch_raw_positions(intent.symbol)
274
+ plan = plan_reversal(intent.side, intent.qty, legs)
275
+ # Pre-flight partial-leg capability BEFORE anything is persisted or
276
+ # sent — same atomicity rationale as the volume-bounds check below.
277
+ await self._reject_unsupported_partial_closes(
278
+ plan.closes, legs, symbol=intent.symbol,
279
+ intent_key=intent.intent_key, port=port,
280
+ )
281
+ # Pre-flight the broker volume bounds BEFORE any close lands: an
282
+ # out-of-range order must raise the non-halting skip while it is still
283
+ # true, otherwise the closes reduce the book yet the whole reversal is
284
+ # reported skipped, desyncing the engine. A pure reduce (open_qty == 0)
285
+ # validates the combined order size; a real reversal validates its
286
+ # residual leg.
287
+ preflight_qty = plan.open_qty if plan.open_qty > 0.0 else intent.qty
288
+ await port.reject_out_of_range(envelope, preflight_qty)
289
+ parent_coid = envelope.client_order_id(KIND_CLOSE)
290
+ # Persist-first residual-open breadcrumb BEFORE the closes: a crash
291
+ # after the closes land but before ``place_leg`` persists the residual
292
+ # entry row would otherwise leave the book reduced with the residual
293
+ # open lost — neither the close-leg replay nor the entry journal owns
294
+ # it. ``restart_replay`` reconciles this against the residual entry row
295
+ # and re-dispatches only if that row never landed. Only a genuine
296
+ # reversal needs it: a pure add (no opposing legs, ``plan.closes``
297
+ # empty) leaves the book intact, so re-opening it on restart would
298
+ # bypass the next sync's Pine re-evaluation and resurrect an entry the
299
+ # strategy may no longer want. The breadcrumb is gated on closes being
300
+ # actually owed, never on a positive ``open_qty`` alone.
301
+ residual_coid = f"{parent_coid}:residual"
302
+ wrote_breadcrumb = bool(plan.closes) and plan.open_qty > 0.0
303
+ if wrote_breadcrumb and self._store_ctx is not None:
304
+ # The breadcrumb's entry coid must match the kind ``place_leg`` will
305
+ # persist under: a stop-fired market entry dispatches as
306
+ # ``KIND_ENTRY_STOP``, so anchoring on ``KIND_ENTRY`` there would
307
+ # make the replay's row-existence check miss the landed entry row
308
+ # and double-open the residual.
309
+ entry_kind = KIND_ENTRY_STOP if intent.stop_fired_market else KIND_ENTRY
310
+ create_residual_open_row(
311
+ self._store_ctx,
312
+ coid=residual_coid,
313
+ symbol=intent.symbol,
314
+ side=intent.side,
315
+ qty=plan.open_qty,
316
+ intent_key=intent.intent_key,
317
+ pine_entry_id=intent.pine_id,
318
+ entry_coid=envelope.client_order_id(entry_kind),
319
+ run_tag=envelope.run_tag,
320
+ bar_ts_ms=envelope.bar_ts_ms,
321
+ retry_seq=envelope.retry_seq,
322
+ )
323
+ dispatched: list[tuple[str, int]] = []
324
+ if plan.closes:
325
+ try:
326
+ dispatched = await self._fan_out_closes(
327
+ plan.closes, symbol=intent.symbol, side=intent.side,
328
+ intent_key=intent.intent_key, pine_id=intent.pine_id,
329
+ parent_coid=parent_coid, port=port,
330
+ )
331
+ except ExchangeOrderRejectedError:
332
+ # A close leg was DEFINITIVELY refused — the fan-out raised
333
+ # before the residual ``place_leg`` ran, so the residual never
334
+ # opened. ``_dispatch_new`` turns this reject into a non-halting
335
+ # skip for the entry (signal dropped, bot continues), so the
336
+ # breadcrumb must be discharged here exactly as the residual
337
+ # reject path below does: leaving it live would let a later
338
+ # restart replay re-close + re-open a reversal the exchange
339
+ # already rejected and Pine never re-signalled. An ambiguous
340
+ # ``OrderDispositionUnknownError`` (caught by the broader
341
+ # ``BrokerError`` contract, not here) leaves the close's fate —
342
+ # and thus a possibly-owed residual — unknown, so its breadcrumb
343
+ # must survive for restart reconciliation and is left intact.
344
+ if wrote_breadcrumb and self._store_ctx is not None:
345
+ clear_residual_open_row(self._store_ctx, residual_coid)
346
+ raise
347
+ opened_orders: tuple = ()
348
+ if plan.open_qty > 0.0:
349
+ try:
350
+ opened_orders = tuple(await port.place_leg(envelope, plan.open_qty))
351
+ except ExchangeOrderRejectedError:
352
+ # The exchange definitively refused the residual entry — nothing
353
+ # opened. The dispatch path turns this into a non-halting skip
354
+ # (signal dropped, bot continues), so the breadcrumb must be
355
+ # discharged here: leaving it live would let a later restart
356
+ # replay re-open a residual the exchange already rejected and Pine
357
+ # never re-signalled. Only a DEFINITIVE reject clears it — an
358
+ # ambiguous ``OrderDispositionUnknownError`` / connection error
359
+ # leaves the open's fate unknown, so its breadcrumb must survive
360
+ # for restart reconciliation and is left intact (re-raised).
361
+ if wrote_breadcrumb and self._store_ctx is not None:
362
+ clear_residual_open_row(self._store_ctx, residual_coid)
363
+ raise
364
+ if wrote_breadcrumb and self._store_ctx is not None:
365
+ # The residual entry row is now persisted (persist-first inside
366
+ # ``place_leg``) and dispatched — the breadcrumb is discharged.
367
+ clear_residual_open_row(self._store_ctx, residual_coid)
368
+ return ReversalFanResult(
369
+ closes=tuple(dispatched), open_qty=plan.open_qty,
370
+ opened_orders=opened_orders,
371
+ )
372
+
373
+ @staticmethod
374
+ async def _reject_unsupported_partial_closes(
375
+ closes: tuple[LegClose, ...], legs: list[PositionLeg], *,
376
+ symbol: str, intent_key: str, port: 'PositionPort',
377
+ ) -> None:
378
+ """Atomically skip a close plan containing a partial leg slice on a
379
+ port that cannot express one.
380
+
381
+ A venue without a per-leg partial reduce (Capital.com's
382
+ ``DELETE /positions/{dealId}`` is full-row only) declares
383
+ ``supports_partial_leg_close = False`` on its port; an absent
384
+ attribute means supported. The check runs BEFORE any close-leg row
385
+ is persisted or dispatched, so the skip leaves no half-reduced book
386
+ and nothing for restart replay to resume — raising from inside the
387
+ fan (the partial tail is dispatched last, FIFO) would desync the
388
+ engine exactly like the volume-bounds case documented in
389
+ :meth:`run_reversal`. Volumes are compared on the broker grid so
390
+ float noise in the Pine-unit plan cannot fake a partial.
391
+ """
392
+ if getattr(port, 'supports_partial_leg_close', True):
393
+ return
394
+ if not closes:
395
+ return
396
+ quantize = await port.get_volume_quantizer(symbol)
397
+ live_by_id = {leg.leg_id: leg for leg in legs}
398
+ for close in closes:
399
+ live = live_by_id.get(close.leg_id)
400
+ if live is None:
401
+ continue
402
+ if quantize(close.qty) < quantize(live.qty):
403
+ raise OrderSkippedByPlugin(
404
+ f"Skipping {symbol} close: leg {close.leg_id} would be "
405
+ f"reduced partially ({close.qty} of {live.qty}) but this "
406
+ f"broker only supports whole-leg closes on a hedging "
407
+ f"account. No order sent; partial closes need a one-way "
408
+ f"(netting) account.",
409
+ intent_key=intent_key,
410
+ reason="partial_leg_close_unsupported",
411
+ context={
412
+ 'symbol': symbol,
413
+ 'leg_id': close.leg_id,
414
+ 'close_qty': close.qty,
415
+ 'leg_qty': live.qty,
416
+ },
417
+ )
418
+
419
+ async def _fan_out_closes(
420
+ self, closes: tuple[LegClose, ...], *,
421
+ symbol: str, side: str, intent_key: str, pine_id: str,
422
+ parent_coid: str, port: 'PositionPort',
423
+ ) -> list[tuple[str, int]]:
424
+ """Snap a FIFO close plan to the broker grid and dispatch it per leg.
425
+
426
+ Shared by :meth:`run_close` and :meth:`run_reversal`. Each leg is
427
+ PERSIST-FIRST: its row (coid ``f"{parent_coid}:{leg_id}"``, so a restart
428
+ re-dispatch upserts the SAME row) is written ``pending`` BEFORE the wire
429
+ call and finalised ``dispatched`` only after
430
+ :meth:`PositionPort.close_leg` returns. Returns the
431
+ ``(leg_id, broker-grid volume)`` pairs actually sent (empty when the
432
+ whole plan rounded below the grid).
433
+ """
434
+ quantize = await port.get_volume_quantizer(symbol)
435
+ dispatched = plan_leg_close_volumes(closes, quantize)
436
+ for leg_id, volume in dispatched:
437
+ leg_coid = f"{parent_coid}:{leg_id}"
438
+ if self._store_ctx is not None:
439
+ create_close_leg_row(
440
+ self._store_ctx,
441
+ coid=leg_coid,
442
+ symbol=symbol,
443
+ side=side,
444
+ qty=float(volume),
445
+ intent_key=intent_key,
446
+ pine_entry_id=pine_id,
447
+ parent_close_coid=parent_coid,
448
+ leg_id=leg_id,
449
+ leg_volume=volume,
450
+ )
451
+ await port.close_leg(symbol, leg_id, volume, leg_coid)
452
+ if self._store_ctx is not None:
453
+ update_close_leg_state(
454
+ self._store_ctx, coid=leg_coid,
455
+ new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
456
+ )
457
+ return dispatched
458
+
459
+ # === Exit-bracket replication =========================================
460
+
461
+ async def run_exit_bracket(
462
+ self, envelope: 'DispatchEnvelope', port: 'PositionPort',
463
+ ) -> BracketFanResult:
464
+ """Replicate a Pine exit's bracket onto the net-survivor position legs.
465
+
466
+ Hedging venues carry protective levels per position, so a one-way bracket
467
+ over a multi-leg position is delivered by amending the SAME
468
+ TP/SL/trailing onto each leg that makes up the net one-way position. On a
469
+ mixed book (a manual hedge or a crash-interrupted reversal left opposing
470
+ legs open) only the legs that survive virtual-FIFO netting are protected
471
+ (:func:`net_survivor_legs`), never the gross majority side — amending the
472
+ whole majority side would close more than the net position when the stop
473
+ fires and flip it to the minority side. Each leg is recorded PERSIST-FIRST
474
+ in the ownership index (keyed by the exit's ``intent_key``) BEFORE its
475
+ amend, so :meth:`run_exit_bracket_clear` later clears ONLY the legs this
476
+ exit owns and :meth:`restart_replay` can re-assert or release them. A flat
477
+ symbol is a non-halting skip.
478
+
479
+ Re-running it (a modify, or a re-attach after pyramiding) upserts the
480
+ same per-leg rows idempotently: the row key is STABLE per (exit
481
+ identity, leg) — derived from the exit's pine_id + from_entry + leg_id,
482
+ NOT the bar-varying dispatch coid — so a modify on a later bar updates
483
+ the same rows instead of accreting stale ones. Two exits sharing a
484
+ pine_id but differing in from_entry get disjoint rows even on a shared
485
+ leg (the dispatch coid alone would collide — it encodes pine_id but not
486
+ from_entry). Legs that have since closed are released by the restart /
487
+ reconcile pass, not here.
488
+ """
489
+ intent = envelope.intent
490
+ assert isinstance(intent, ExitIntent)
491
+ if intent.trail_price is not None and intent.trail_offset is not None:
492
+ # The PositionPort amend surface carries no ``trail_price`` — the
493
+ # deferred-activation trailing of the direct paths is not
494
+ # expressible per-leg, so the trailing arms IMMEDIATELY at
495
+ # ``trail_offset``. Trades can exit earlier/tighter than the Pine
496
+ # simulation; surface it instead of diverging silently.
497
+ _blog_warning(
498
+ "one-way bracket replication: trail_price activation is not "
499
+ "expressible per-leg — trailing on %s (exit %r) arms "
500
+ "immediately at offset %s instead of after the activation "
501
+ "level %s",
502
+ intent.symbol, intent.pine_id, intent.trail_offset,
503
+ intent.trail_price,
504
+ )
505
+ legs = await port.fetch_raw_positions(intent.symbol)
506
+ side, on_side = net_survivor_legs(legs)
507
+ if side == 'flat' or not on_side:
508
+ return BracketFanResult(legs=(), skipped=True)
509
+ survivors = {leg.leg_id for leg in on_side}
510
+ # A re-attach can narrow the survivor set: a manual hedge or an
511
+ # interrupted reversal opens an opposing leg that virtually FIFO-consumes
512
+ # the oldest majority leg, so :func:`net_survivor_legs` now drops a leg
513
+ # this exit owned on a prior attach. That leg is still PHYSICALLY open
514
+ # (only virtually netted) and still carries the broker bracket from the
515
+ # earlier attach; its ownership row would otherwise stay ``active`` and the
516
+ # restart pass would re-assert the stale stop, which on a hit closes more
517
+ # than the net exposure and flips the book. Clear + release those dropped
518
+ # legs FIRST: a survivor amend below can raise
519
+ # :class:`OrderDispositionUnknownError`, which the dispatch path parks
520
+ # while promoting the NEW intent into ``_active_intents`` — the next diff
521
+ # then sees Pine == active and never re-runs this fan, so a dropped-leg
522
+ # clear left AFTER the survivor loop would never get retried. The dropped
523
+ # set is disjoint from ``survivors`` (the clear skips survivor rows), so
524
+ # ordering it before the amend loop is otherwise behaviour-neutral.
525
+ await self._clear_dropped_survivor_legs(
526
+ envelope, intent, survivors=survivors, port=port,
527
+ )
528
+ # Bar-varying dispatch coid (matches the plugin's per-leg amend anchor);
529
+ # one per bracket attach, reused across its legs.
530
+ attach_coid = envelope.client_order_id(KIND_EXIT_SL)
531
+ replicated: list[str] = []
532
+ for leg in on_side:
533
+ own_coid = self._ownership_coid(intent.intent_key, leg.leg_id)
534
+ if self._store_ctx is not None:
535
+ create_bracket_ownership_row(
536
+ self._store_ctx,
537
+ coid=own_coid,
538
+ symbol=intent.symbol,
539
+ side=intent.side,
540
+ qty=intent.qty,
541
+ intent_key=intent.intent_key,
542
+ pine_entry_id=intent.pine_id,
543
+ from_entry=intent.from_entry,
544
+ leg_id=leg.leg_id,
545
+ attach_coid=attach_coid,
546
+ tp_price=intent.tp_price,
547
+ sl_price=intent.sl_price,
548
+ trail_price=intent.trail_price,
549
+ trail_offset=intent.trail_offset,
550
+ oca_name=intent.oca_name,
551
+ oca_type=intent.oca_type,
552
+ )
553
+ try:
554
+ await port.amend_bracket(
555
+ intent.symbol, leg.leg_id, side=intent.side,
556
+ tp_price=intent.tp_price, sl_price=intent.sl_price,
557
+ trail_offset=self._trail_price_distance(intent.trail_offset),
558
+ coid=attach_coid,
559
+ )
560
+ except ExchangeOrderRejectedError as exc:
561
+ # The amend was DEFINITIVELY rejected on a leg of an OPEN
562
+ # position (we hold ``on_side`` legs), so the position is now
563
+ # open and UNPROTECTED. PERSIST-FIRST wrote this leg's ownership
564
+ # row before the call, but a reject means we are still alive (not
565
+ # a crash), so release that one never-attached row synchronously
566
+ # — leaving it active would make a later ownership-scoped clear
567
+ # or restart replay believe this exit protects a leg it does not.
568
+ # The legs already amended earlier in this fan keep their active
569
+ # rows: those brackets DO exist, and the defensive close the
570
+ # engine issues for the wrapped error flattens the whole
571
+ # position. (A leg that vanished mid-fan surfaces NOT as a reject
572
+ # but as the port's benign no-op, so it never reaches here; an
573
+ # ambiguous timeout is intentionally not caught — the amend may
574
+ # have landed, so its row stays active for replay / reconcile.)
575
+ if self._store_ctx is not None:
576
+ update_bracket_ownership_state(
577
+ self._store_ctx, coid=own_coid,
578
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
579
+ )
580
+ raise BracketAttachAfterFillRejectedError(
581
+ f"one-way bracket attach rejected after fill "
582
+ f"(leg={leg.leg_id}, from_entry={intent.from_entry!r}): {exc}",
583
+ position_coid=f"__pyne_orphan__{intent.symbol}__{intent.from_entry}",
584
+ symbol=intent.symbol,
585
+ position_side=('buy' if intent.side == 'sell' else 'sell'),
586
+ qty=intent.qty,
587
+ position_deal_id=leg.leg_id,
588
+ from_entry=intent.from_entry,
589
+ exit_id=intent.pine_id,
590
+ ) from exc
591
+ replicated.append(leg.leg_id)
592
+ return BracketFanResult(legs=tuple(replicated), skipped=False)
593
+
594
+ async def _clear_dropped_survivor_legs(
595
+ self, envelope: 'DispatchEnvelope', intent: ExitIntent, *,
596
+ survivors: set[str], port: 'PositionPort',
597
+ ) -> None:
598
+ """Clear + release this exit's owned legs that fell out of the survivor set.
599
+
600
+ On a re-attach the net survivor set can shrink (an opposing leg now
601
+ virtually FIFO-consumes a leg this exit protected before). Each such leg is
602
+ still physically open and still carries the prior broker bracket, so its
603
+ active ownership row is cleared on the broker (amend-to-None) under a clear
604
+ coid distinct from the attach coid, then released — mirroring
605
+ :meth:`run_exit_bracket_clear` for the implicit drop. A ``*_NOT_FOUND``
606
+ race on a vanished leg is the port's benign no-op; the restart pass would
607
+ otherwise re-assert the stale stop.
608
+
609
+ An ambiguous clear (:class:`OrderDispositionUnknownError`) on a dropped
610
+ leg must NOT abort this call: :meth:`run_exit_bracket` runs the drop FIRST,
611
+ before the survivor amend loop, so propagating here would skip the NEW
612
+ protection on the surviving legs entirely — the dispatch path then parks
613
+ the error while promoting the new intent into ``_active_intents``, so the
614
+ next diff sees Pine == active, never re-runs the fan, and the survivors
615
+ stay on STALE TP/SL indefinitely. The drop is persist-first ``clearing``,
616
+ so leaving its row in that phase lets :meth:`drain_clearing_rows` (per
617
+ sync) and :meth:`restart_replay` retry the idempotent re-clear; the
618
+ survivor amend below proceeds with the new levels. This mirrors the
619
+ unknown-disposition handling in :meth:`drain_clearing_rows`.
620
+ """
621
+ if self._store_ctx is None:
622
+ return
623
+ clear_coid = envelope.client_order_id(KIND_CANCEL)
624
+ rows = list(iter_active_bracket_ownerships(
625
+ self._store_ctx, symbol=intent.symbol, from_entry=intent.from_entry,
626
+ ))
627
+ for row in rows:
628
+ if (row.intent_key or '') != intent.intent_key:
629
+ continue
630
+ leg_id: str | None = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
631
+ if leg_id is None or leg_id in survivors:
632
+ continue
633
+ update_bracket_ownership_state(
634
+ self._store_ctx, coid=row.client_order_id,
635
+ new_state=BRACKET_OWN_STATE_CLEARING,
636
+ extras_patch={EXTRAS_KEY_BRACKET_OWN_CLEAR_COID: clear_coid},
637
+ )
638
+ try:
639
+ await port.amend_bracket(
640
+ intent.symbol, leg_id, side=row.side,
641
+ tp_price=None, sl_price=None, trail_offset=None,
642
+ coid=clear_coid,
643
+ )
644
+ except OrderDispositionUnknownError:
645
+ # The clear did not confirm; leave the row ``clearing`` so
646
+ # ``drain_clearing_rows`` / ``restart_replay`` retry the
647
+ # idempotent re-clear, and DO NOT abort the survivor amend
648
+ # below — those legs need their new protection now.
649
+ continue
650
+ update_bracket_ownership_state(
651
+ self._store_ctx, coid=row.client_order_id,
652
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
653
+ )
654
+
655
+ async def drain_clearing_rows(self, symbol: str, port: 'PositionPort') -> set[str]:
656
+ """Re-clear + release any ``clearing`` bracket-ownership rows in-session.
657
+
658
+ :meth:`_clear_dropped_survivor_legs` (and :meth:`run_exit_bracket_clear`)
659
+ mark a row ``clearing`` PERSIST-FIRST, then amend-to-None on the broker.
660
+ An ambiguous :class:`OrderDispositionUnknownError` on that amend leaves the
661
+ row stranded in ``clearing``: the dispatch path parks the exit and promotes
662
+ it into ``_active_intents``, so the next diff sees Pine == active and never
663
+ re-runs the owning fan, and the one-way orphan sweep skips active keys —
664
+ the stale leg stays armed until the next restart's :meth:`restart_replay`.
665
+
666
+ This drain closes that window. The engine calls it once per sync (after the
667
+ orphan sweep): every live ``clearing`` row is re-cleared under its persisted
668
+ clear coid and released, exactly like :meth:`_replay_bracket_one`'s clearing
669
+ branch but per-sync rather than restart-only. A ``clearing`` row is by
670
+ definition marked-for-removal, NEVER wanted protection, so re-clearing it is
671
+ always safe even while the owning exit is still active (its survivor legs
672
+ carry ``active`` rows, untouched here). A vanished leg's bracket is moot —
673
+ release the row. Amending to None is idempotent (an already-cleared leg
674
+ no-ops), so a re-clear that already landed is a broker no-op. A timeout on
675
+ the re-clear leaves the row ``clearing`` for the next sync to retry rather
676
+ than halting the bot.
677
+
678
+ Returns the set of ``intent_key`` values whose LAST surviving ownership row
679
+ the drain released this call. The engine drops the in-memory
680
+ envelope/mapping for any such key whose owning exit Pine no longer emits —
681
+ the orphan sweep's direct-clear timeout (``continue`` before
682
+ ``_drop_envelope``) leaves that engine state behind, and the drain
683
+ releasing the row here is the second half of the same retirement. Keys that
684
+ still carry an ``active`` row (a live exit's survivor legs) are excluded, so
685
+ a live bracket never loses its anchor.
686
+ """
687
+ if self._store_ctx is None:
688
+ return set()
689
+ rows = [
690
+ row
691
+ for row in iter_active_bracket_ownerships(self._store_ctx, symbol=symbol)
692
+ if (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_STATE)
693
+ == BRACKET_OWN_STATE_CLEARING
694
+ ]
695
+ if not rows:
696
+ return set()
697
+ legs = await port.fetch_raw_positions(symbol)
698
+ live_ids = {leg.leg_id for leg in legs}
699
+ released: set[str] = set()
700
+ for row in rows:
701
+ extras = row.extras or {}
702
+ leg_id: str | None = extras.get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
703
+ if leg_id is not None and leg_id in live_ids:
704
+ try:
705
+ await port.amend_bracket(
706
+ symbol, leg_id, side=row.side,
707
+ tp_price=None, sl_price=None, trail_offset=None,
708
+ coid=extras.get(EXTRAS_KEY_BRACKET_OWN_CLEAR_COID)
709
+ or row.client_order_id,
710
+ )
711
+ except OrderDispositionUnknownError:
712
+ # Re-clear did not confirm; leave the row ``clearing`` so the
713
+ # next sync retries it (idempotent amend-to-None). Halting here
714
+ # would strand the bot on a recoverable broker round-trip.
715
+ continue
716
+ except ExchangeConnectionError:
717
+ # The connection dropped mid-drain. Earlier rows in this loop
718
+ # may already be RELEASED (durably closed in the store) and
719
+ # their keys collected in ``released``; if we let the exception
720
+ # escape, that partial progress is lost — the caller logs +
721
+ # retries but never runs the envelope/mapping retirement, so a
722
+ # released row's stale anchor survives and a later re-emission
723
+ # rebuilds the same attach coid an idempotent plugin dedups,
724
+ # leaving the leg unprotected. Stop draining (the link is down)
725
+ # but RETURN what was already released so the engine retires it;
726
+ # this row stays ``clearing`` for the next sync to retry.
727
+ break
728
+ update_bracket_ownership_state(
729
+ self._store_ctx, coid=row.client_order_id,
730
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
731
+ )
732
+ if row.intent_key is not None:
733
+ released.add(row.intent_key)
734
+ # Only report a key whose every ownership row is now gone: a key still
735
+ # carrying an ``active`` row (a live exit's survivor legs) must keep its
736
+ # envelope, so the engine must not retire it.
737
+ if not released:
738
+ return released
739
+ still_owned = {
740
+ row.intent_key
741
+ for row in iter_active_bracket_ownerships(self._store_ctx, symbol=symbol)
742
+ if row.intent_key is not None
743
+ }
744
+ return released - still_owned
745
+
746
+ async def drain_residual_opens(self, symbol: str, port: 'PositionPort') -> None:
747
+ """Reconcile any live reversal residual-open breadcrumb per sync (in-session).
748
+
749
+ The restart counterpart :meth:`_replay_residual_opens` reconciles these
750
+ breadcrumbs once at startup, but the engine latches that replay after the
751
+ first sync (``_one_way_replay_done``). Two paths leave a breadcrumb live
752
+ mid-session: :meth:`run_reversal`'s residual ``place_leg`` raising
753
+ :class:`OrderDispositionUnknownError` (the open's fate unknown, so the
754
+ breadcrumb is intentionally NOT discharged), and :meth:`_replay_residual_one`
755
+ hitting the same on its own re-dispatch. Without this drain such a breadcrumb
756
+ would wait for the NEXT process restart before being reconciled.
757
+
758
+ This closes that window. The engine calls it once per sync (alongside
759
+ :meth:`drain_clearing_rows`), reconciling every live breadcrumb for
760
+ ``symbol`` through the SAME :meth:`_replay_residual_one` path restart replay
761
+ uses: if the residual's persist-first entry row already landed the breadcrumb
762
+ is simply discharged (the entry / recovery path owns the open), otherwise the
763
+ residual is re-opened under its deterministic entry coid (``KIND_ENTRY``, or
764
+ ``KIND_ENTRY_STOP`` for a stop-fired reversal) — a duplicate is impossible
765
+ at the exchange dedup, so a per-sync re-dispatch cannot double-open. A still-ambiguous re-dispatch leaves the row live for the
766
+ next sync to retry rather than halting the bot, mirroring
767
+ :meth:`drain_clearing_rows`. A successful in-session reversal discharges its
768
+ own breadcrumb synchronously, so this only ever sees genuinely unresolved
769
+ rows. Scoped to ``symbol`` (the engine owns one symbol); a breadcrumb for
770
+ another symbol is that engine's drain to reconcile.
771
+ """
772
+ if self._store_ctx is None:
773
+ return
774
+ for row in list(iter_active_residual_opens(self._store_ctx, symbol=symbol)):
775
+ await self._replay_residual_one(row, port)
776
+
777
+ @staticmethod
778
+ def _ownership_coid(intent_key: str, leg_id: str) -> str:
779
+ """Stable per-(exit, leg) bracket-ownership row key.
780
+
781
+ Keyed on the exit's ``intent_key`` (pine_id + from_entry) and the broker
782
+ leg id, with no bar timestamp, so a modify re-attach upserts the SAME row
783
+ and two exits sharing a pine_id but differing in from_entry never collide
784
+ on a shared leg. Distinct from the bar-varying dispatch coid.
785
+ """
786
+ return f"bo:{intent_key}:{leg_id}"
787
+
788
+ async def run_exit_bracket_clear(
789
+ self, envelope: 'DispatchEnvelope', port: 'PositionPort',
790
+ ) -> ClearFanResult:
791
+ """Clear ONLY the legs the cancelled exit owns (the per-leg ownership fix).
792
+
793
+ A broadcast clear amends every position-side leg, stripping a bracket a
794
+ DIFFERENT exit set. This consults the persisted ownership index instead:
795
+ it amends-to-clear only the legs whose row matches the cancel's
796
+ ``intent_key`` (or, for a cancel-all-by-pine_id with ``from_entry`` None,
797
+ every row whose exit shares that pine_id), then releases each cleared
798
+ row. Without a persisted index (``store_ctx`` None) there is nothing to
799
+ clear — a benign empty result.
800
+ """
801
+ intent = envelope.intent
802
+ assert isinstance(intent, CancelIntent)
803
+ if self._store_ctx is None:
804
+ return ClearFanResult(legs=())
805
+ cancel_coid = envelope.client_order_id(KIND_CANCEL)
806
+ rows = list(iter_active_bracket_ownerships(
807
+ self._store_ctx, symbol=intent.symbol, from_entry=intent.from_entry,
808
+ ))
809
+ cleared: list[str] = []
810
+ for row in rows:
811
+ if not self._owns(row, intent):
812
+ continue
813
+ leg_id: str | None = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
814
+ if leg_id is None:
815
+ continue
816
+ # PERSIST-FIRST: mark the row ``clearing`` BEFORE the amend, so an
817
+ # ambiguous (timed-out) clear leaves a row that :meth:`restart_replay`
818
+ # re-CLEARS rather than re-asserting the original bracket — the
819
+ # close-leg ``pending`` -> ``dispatched`` two-phase applied to the
820
+ # clear. Without it a timed-out clear stays ``active`` and the next
821
+ # restart resurrects the bracket the script asked to cancel. Record the
822
+ # clear coid too, so the restart re-clears under the SAME clear coid —
823
+ # NOT the attach coid, which a coid-idempotent plugin could dedup as a
824
+ # repeat of the attach and swallow, leaving the bracket armed.
825
+ update_bracket_ownership_state(
826
+ self._store_ctx, coid=row.client_order_id,
827
+ new_state=BRACKET_OWN_STATE_CLEARING,
828
+ extras_patch={EXTRAS_KEY_BRACKET_OWN_CLEAR_COID: cancel_coid},
829
+ )
830
+ await port.amend_bracket(
831
+ intent.symbol, leg_id, side=row.side,
832
+ tp_price=None, sl_price=None, trail_offset=None,
833
+ coid=cancel_coid,
834
+ )
835
+ update_bracket_ownership_state(
836
+ self._store_ctx, coid=row.client_order_id,
837
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
838
+ )
839
+ cleared.append(leg_id)
840
+ return ClearFanResult(legs=tuple(cleared))
841
+
842
+ @staticmethod
843
+ def _owns(row, intent: 'CancelIntent') -> bool:
844
+ """Does ``row`` belong to the exit ``intent`` cancels?
845
+
846
+ Exact ``intent_key`` match when the cancel targets one (pine_id,
847
+ from_entry); an ``f"{pine_id}\\0"`` prefix match for a cancel-all
848
+ (``from_entry`` None) that drops every exit sharing the pine_id.
849
+ """
850
+ row_key = row.intent_key or ''
851
+ if intent.from_entry is not None:
852
+ return row_key == intent.intent_key
853
+ return row_key.startswith(f"{intent.pine_id}\0")
854
+
855
+ # === Restart replay ===================================================
856
+
857
+ async def restart_replay(self, port: 'PositionPort') -> None:
858
+ """Resume any close-leg fan-out or bracket replication a crash interrupted.
859
+
860
+ Called once at startup. Three ordered passes: pending close-leg rows
861
+ are reconciled against the live legs and the residual re-dispatched (an
862
+ already-settled leg is never re-closed); THEN reversal residual-open
863
+ breadcrumbs are reconciled (the closes are now resolved, so a still-owed
864
+ residual is safely re-opened) and the open re-dispatched only when its
865
+ own entry row never landed; THEN active bracket-ownership rows are
866
+ re-asserted on still-open legs (idempotent on the per-leg coid) and
867
+ released when their leg has vanished.
868
+ """
869
+ if self._store_ctx is None:
870
+ return
871
+ await self._replay_close_legs(port)
872
+ await self._replay_residual_opens(port)
873
+ await self._replay_bracket_ownership(port)
874
+
875
+ async def _replay_close_legs(self, port: 'PositionPort') -> None:
876
+ """Reconcile + re-dispatch any pending close-leg rows (first replay pass).
877
+
878
+ For every persisted ``pending`` close-leg row, reconcile against the live
879
+ broker legs before re-sending: if the leg is gone, the close already
880
+ landed — finalise the row without dispatching; if it is still open,
881
+ re-dispatch only the residual (capped at the live leg size, re-snapped to
882
+ the grid) so an already-partly-closed leg is never over-reduced. The
883
+ natural-close fill dedup guards the fill side.
884
+ """
885
+ if self._store_ctx is None:
886
+ return
887
+ pending = list(iter_active_close_legs(self._store_ctx))
888
+ if not pending:
889
+ return
890
+ by_symbol: dict[str, list] = {}
891
+ for row in pending:
892
+ by_symbol.setdefault(row.symbol, []).append(row)
893
+ for symbol, rows in by_symbol.items():
894
+ legs = await port.fetch_raw_positions(symbol)
895
+ live_by_id = {leg.leg_id: leg for leg in legs}
896
+ quantize = await port.get_volume_quantizer(symbol)
897
+ for row in rows:
898
+ await self._replay_one(row, symbol, live_by_id, quantize, port)
899
+
900
+ async def _replay_one(
901
+ self, row, symbol: str, live_by_id: dict[str, PositionLeg], quantize, port: 'PositionPort',
902
+ ) -> None:
903
+ """Reconcile + (if needed) re-dispatch one pending close-leg row."""
904
+ if self._store_ctx is None:
905
+ return
906
+ extras = row.extras or {}
907
+ leg_id: str | None = extras.get(EXTRAS_KEY_CLOSE_LEG_ID)
908
+ live_leg = live_by_id.get(leg_id) if leg_id is not None else None
909
+ if live_leg is None or leg_id is None:
910
+ # Leg vanished — the close landed before the crash. Finalise only.
911
+ update_close_leg_state(
912
+ self._store_ctx, coid=row.client_order_id,
913
+ new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
914
+ )
915
+ return
916
+ persisted = extras.get(EXTRAS_KEY_CLOSE_LEG_VOLUME) or 0
917
+ residual = min(int(persisted), quantize(live_leg.qty))
918
+ if residual > 0:
919
+ await port.close_leg(symbol, leg_id, residual, row.client_order_id)
920
+ update_close_leg_state(
921
+ self._store_ctx, coid=row.client_order_id,
922
+ new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
923
+ )
924
+
925
+ async def _replay_residual_opens(self, port: 'PositionPort') -> None:
926
+ """Re-dispatch any reversal residual-open a crash left un-persisted.
927
+
928
+ Runs AFTER :meth:`_replay_close_legs`, so the reversal's FIFO closes are
929
+ already reconciled and re-opening the residual cannot race them. For each
930
+ live breadcrumb: if the residual's own entry row already exists, the
931
+ ``place_leg`` persist-first write landed and the entry journal / startup
932
+ recovery own it — just clear the breadcrumb; otherwise rebuild the
933
+ dispatch envelope and re-open the residual under the SAME deterministic
934
+ entry coid (``KIND_ENTRY``, or ``KIND_ENTRY_STOP`` for a stop-fired
935
+ reversal; a duplicate is therefore impossible at the exchange dedup),
936
+ then clear it.
937
+ """
938
+ if self._store_ctx is None:
939
+ return
940
+ for row in list(iter_active_residual_opens(self._store_ctx)):
941
+ await self._replay_residual_one(row, port)
942
+
943
+ async def _replay_residual_one(self, row, port: 'PositionPort') -> None:
944
+ """Reconcile + (if needed) re-dispatch one residual-open breadcrumb."""
945
+ if self._store_ctx is None:
946
+ return
947
+ extras = row.extras or {}
948
+ entry_coid: str | None = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID)
949
+ if (entry_coid is not None
950
+ and self._store_ctx.get_order(entry_coid) is not None):
951
+ # ``place_leg`` reached its persist-first entry-row write before the
952
+ # crash — the open is durable and owned by the entry path. Discharge.
953
+ clear_residual_open_row(self._store_ctx, row.client_order_id)
954
+ return
955
+ # The residual open never persisted (persist-first guarantees the entry
956
+ # row precedes any wire send, so its absence proves no dispatch happened —
957
+ # re-opening cannot double-open). The breadcrumb is written BEFORE the
958
+ # FIFO closes, so a crash in that window can also leave the opposing legs
959
+ # un-closed AND their close-leg rows un-persisted — ``_replay_close_legs``
960
+ # then has nothing to re-dispatch. Re-opening the residual on top of those
961
+ # still-live opposing legs would over-expose the book. Reconcile against
962
+ # the live legs first: a genuine reversal (positive residual) fully
963
+ # consumes the opposing exposure, so every still-live opposing leg is an
964
+ # owed close — FIFO-close them under the SAME deterministic parent coid
965
+ # (idempotent at the exchange dedup) before the residual open.
966
+ run_tag = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG)
967
+ bar_ts_ms = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS)
968
+ retry_seq = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ)
969
+ assert isinstance(run_tag, str)
970
+ assert isinstance(bar_ts_ms, int)
971
+ assert isinstance(retry_seq, int)
972
+ opposing_side = 'sell' if row.side == 'buy' else 'buy'
973
+ live_legs = await port.fetch_raw_positions(row.symbol)
974
+ owed_closes = tuple(
975
+ LegClose(leg_id=leg.leg_id, qty=leg.qty)
976
+ for leg in live_legs
977
+ if leg.side == opposing_side and leg.qty > 0.0
978
+ )
979
+ if owed_closes:
980
+ parent_coid = row.client_order_id.removesuffix(':residual')
981
+ await self._fan_out_closes(
982
+ owed_closes, symbol=row.symbol, side=row.side,
983
+ intent_key=row.intent_key or row.pine_entry_id or '',
984
+ pine_id=row.pine_entry_id or '', parent_coid=parent_coid, port=port,
985
+ )
986
+ # Quarantine gate: the reopen leg is a new-exposure dispatch. The
987
+ # owed FIFO closes above still ran (risk-reducing), but the reopen
988
+ # is withheld and the breadcrumb stays live — the committed
989
+ # reversal finishes on the replay after the operator restart.
990
+ if (self._block_exposure_reopens is not None
991
+ and self._block_exposure_reopens()):
992
+ _blog_warning(
993
+ "one-way residual reopen %r withheld by quarantine; "
994
+ "breadcrumb stays pending until the operator restart",
995
+ row.client_order_id,
996
+ )
997
+ return
998
+ # Rebuild the intent with the ORIGINAL dispatch's coid kind: a
999
+ # stop-fired reversal's residual went out under ``KIND_ENTRY_STOP``
1000
+ # (the plugin picks the kind from ``stop_fired_market``), so the
1001
+ # replayed ``place_leg`` must persist and dedup under that same coid —
1002
+ # otherwise a second ambiguous round-trip would land an entry row the
1003
+ # breadcrumb's ``entry_coid`` check never finds, re-opening the
1004
+ # residual again. The flag is recovered from the persisted coid's kind
1005
+ # code rather than a separate extras field — both forms carry it raw:
1006
+ # canonical in ``{k}{r}``, wire in the raw prefix. A wire-form coid's
1007
+ # length IS the minting venue's budget, so replaying it at
1008
+ # ``len(entry_coid)`` re-mints the byte-identical id; a canonical coid
1009
+ # replays under the default (identity) budget.
1010
+ parsed_entry = parsed_wire = None
1011
+ coid_max_len = CLIENT_ORDER_ID_MAX_LEN
1012
+ if entry_coid is not None:
1013
+ parsed_entry = parse_client_order_id(entry_coid)
1014
+ if parsed_entry is None:
1015
+ parsed_wire = parse_wire_client_order_id(entry_coid)
1016
+ if parsed_wire is not None:
1017
+ coid_max_len = len(entry_coid)
1018
+ entry_kind = (
1019
+ parsed_entry.kind if parsed_entry is not None
1020
+ else parsed_wire.kind if parsed_wire is not None
1021
+ else None
1022
+ )
1023
+ stop_fired = entry_kind == KIND_ENTRY_STOP
1024
+ envelope = DispatchEnvelope(
1025
+ intent=EntryIntent(
1026
+ pine_id=row.pine_entry_id, symbol=row.symbol, side=row.side,
1027
+ qty=row.qty, order_type=OrderType.MARKET,
1028
+ stop_fired_market=stop_fired,
1029
+ ),
1030
+ run_tag=run_tag, bar_ts_ms=bar_ts_ms, retry_seq=retry_seq,
1031
+ coid_max_len=coid_max_len,
1032
+ )
1033
+ try:
1034
+ await port.place_leg(envelope, row.qty)
1035
+ except ExchangeOrderRejectedError:
1036
+ # The exchange definitively refused the residual entry — nothing
1037
+ # opened. ``restart_replay`` runs inside the sync startup wrapper,
1038
+ # which only catches ``ExchangeConnectionError`` (sync_engine), so
1039
+ # propagating here would abort startup and the breadcrumb would
1040
+ # survive un-cleared, retrying the same rejected residual on every
1041
+ # later sync. Discharge the breadcrumb and stop: the residual the
1042
+ # exchange rejected and Pine never re-signalled must not re-open.
1043
+ clear_residual_open_row(self._store_ctx, row.client_order_id)
1044
+ return
1045
+ except OrderDispositionUnknownError:
1046
+ # The residual entry's fate is unknown (ambiguous round-trip). Leave
1047
+ # the breadcrumb live so the next sync's replay / drain reconciles it
1048
+ # against the then-known leg state, but do NOT propagate — that would
1049
+ # abort startup on a recoverable round-trip, mirroring the bracket
1050
+ # re-clear path above.
1051
+ return
1052
+ clear_residual_open_row(self._store_ctx, row.client_order_id)
1053
+
1054
+ async def _replay_bracket_ownership(self, port: 'PositionPort') -> None:
1055
+ """Re-assert / finish live bracket-ownership rows (second replay pass).
1056
+
1057
+ For every live ownership row: if its leg is still open, an ``active`` row
1058
+ is re-asserted idempotently on the same per-leg coid (the broker no-ops an
1059
+ unchanged amend) while a ``clearing`` row (a clear a crash interrupted) is
1060
+ finished by re-clearing then releasing it; if the leg has vanished, the
1061
+ bracket it carried is moot — release the orphan row so it leaves the live
1062
+ index.
1063
+ """
1064
+ if self._store_ctx is None:
1065
+ return
1066
+ rows = list(iter_active_bracket_ownerships(self._store_ctx))
1067
+ if not rows:
1068
+ return
1069
+ by_symbol: dict[str, list] = {}
1070
+ for row in rows:
1071
+ by_symbol.setdefault(row.symbol, []).append(row)
1072
+ for symbol, srows in by_symbol.items():
1073
+ legs = await port.fetch_raw_positions(symbol)
1074
+ live_ids = {leg.leg_id for leg in legs}
1075
+ for row in srows:
1076
+ await self._replay_bracket_one(row, symbol, live_ids, port)
1077
+
1078
+ async def _replay_bracket_one(
1079
+ self, row, symbol: str, live_ids: set, port: 'PositionPort',
1080
+ ) -> None:
1081
+ """Re-assert / finish one bracket-ownership row, or release a vanished leg."""
1082
+ if self._store_ctx is None:
1083
+ return
1084
+ extras = row.extras or {}
1085
+ leg_id: str | None = extras.get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
1086
+ if leg_id is None or leg_id not in live_ids:
1087
+ update_bracket_ownership_state(
1088
+ self._store_ctx, coid=row.client_order_id,
1089
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
1090
+ )
1091
+ return
1092
+ if extras.get(EXTRAS_KEY_BRACKET_OWN_STATE) == BRACKET_OWN_STATE_CLEARING:
1093
+ # A clear interrupted before it confirmed (crash or ambiguous
1094
+ # timeout): finish it. Amending to None is idempotent — an already
1095
+ # cleared leg no-ops — so re-clear then release, NEVER re-assert the
1096
+ # original levels (that would resurrect a cancelled bracket). Re-clear
1097
+ # under the persisted clear coid (NOT the attach coid): the contract
1098
+ # lets a plugin dedup a repeated coid, so reusing the attach coid here
1099
+ # could be swallowed as a duplicate attach, leaving the bracket armed.
1100
+ try:
1101
+ await port.amend_bracket(
1102
+ symbol, leg_id, side=row.side,
1103
+ tp_price=None, sl_price=None, trail_offset=None,
1104
+ coid=extras.get(EXTRAS_KEY_BRACKET_OWN_CLEAR_COID)
1105
+ or row.client_order_id,
1106
+ )
1107
+ except OrderDispositionUnknownError:
1108
+ # The re-clear did not confirm. ``restart_replay`` runs inside the
1109
+ # sync startup wrapper, which only catches ``ExchangeConnectionError``;
1110
+ # propagating this sibling error would abort startup on a recoverable
1111
+ # ambiguous round-trip. Leave the row ``clearing`` (NOT released) so
1112
+ # the next ``drain_clearing_rows`` / replay retries the idempotent
1113
+ # re-clear, mirroring ``drain_clearing_rows``.
1114
+ return
1115
+ update_bracket_ownership_state(
1116
+ self._store_ctx, coid=row.client_order_id,
1117
+ new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
1118
+ )
1119
+ return
1120
+ await port.amend_bracket(
1121
+ symbol, leg_id, side=row.side,
1122
+ tp_price=extras.get(EXTRAS_KEY_BRACKET_OWN_TP),
1123
+ sl_price=extras.get(EXTRAS_KEY_BRACKET_OWN_SL),
1124
+ trail_offset=self._trail_price_distance(
1125
+ extras.get(EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET)
1126
+ ),
1127
+ coid=extras.get(EXTRAS_KEY_BRACKET_OWN_ATTACH_COID) or row.client_order_id,
1128
+ )