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,1379 @@
1
+ """
2
+ Engine-side trigger state machine for partial-quantity bracket exits.
3
+
4
+ This module owns the lifecycle of a partial-quantity TP / SL / trailing
5
+ bracket that the *engine* watches and fires — as opposed to a native
6
+ broker bracket attached to the parent ``dealId`` (covered by
7
+ ``execute_exit``).
8
+
9
+ The state machine and the persisted leg rows in :mod:`store_helpers`
10
+ form one pair: the rows are the durable representation (PERSIST-FIRST),
11
+ the in-memory :class:`PartialBracketLeg` ledger is the working set the
12
+ WATCH phase reads on every WS price tick. On a clean restart the
13
+ ledger is rebuilt from the rows by :meth:`SoftwarePartialBracketEngine.restart_replay`.
14
+
15
+ The dispatch-side counterpart lives in
16
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._dispatch_engine_trigger_partial_bracket`:
17
+ it writes the leg rows, then hands them to this state machine via
18
+ :meth:`SoftwarePartialBracketEngine.register_legs`. After that the
19
+ machine owns the legs until a terminal state.
20
+
21
+ Lifecycle ownership: the state machine itself, the in-memory ledger,
22
+ the PERSIST-FIRST → ledger handoff, the cascade-cancel paths (OCA,
23
+ parent close, broker-native SL), the restart replay, and the
24
+ close-dispatch settling step
25
+ (:meth:`SoftwarePartialBracketEngine.confirm_trigger_dispatched`,
26
+ :meth:`SoftwarePartialBracketEngine.mark_trigger_dispatch_failed`,
27
+ :meth:`SoftwarePartialBracketEngine.mark_trigger_dispatch_unknown`).
28
+ The price-tick wiring runs from
29
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._drive_partial_bracket_triggers`
30
+ at the tail of every sync cycle for plugins that advertise
31
+ ``partial_qty_bracket_exit = CapabilityLevel.SOFTWARE``: the sync
32
+ engine refreshes the parent snapshot, calls
33
+ :meth:`SoftwarePartialBracketEngine.on_price_tick`, synthesises a
34
+ :class:`CloseIntent` for each triggering leg, and dispatches it
35
+ through the regular ``_dispatch_new`` path before calling the
36
+ matching settling method on the state machine. See the partial-qty
37
+ bracket exit design dossier §3 for the full lifecycle.
38
+ """
39
+ from dataclasses import dataclass, field
40
+ from typing import TYPE_CHECKING, Callable, Iterable
41
+
42
+ from pynecore.core.broker.models import OcaType
43
+ from pynecore.core.broker.store_helpers import (
44
+ EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS,
45
+ EXTRAS_KEY_INTENT_PARTIAL_QTY,
46
+ EXTRAS_KEY_LEG_KIND,
47
+ EXTRAS_KEY_LEG_STATE,
48
+ EXTRAS_KEY_OCA_GROUP,
49
+ EXTRAS_KEY_OCA_TYPE,
50
+ EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF,
51
+ EXTRAS_KEY_PARENT_PINE_ENTRY_ID,
52
+ EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL,
53
+ EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET,
54
+ EXTRAS_KEY_TRIGGER_LEVEL,
55
+ EXTRAS_KEY_TRIGGER_OFFSET,
56
+ LEG_KIND_SL_PARTIAL,
57
+ LEG_KIND_TP_PARTIAL,
58
+ LEG_KIND_TRAIL_PARTIAL,
59
+ LEG_STATE_ABORTED_PARENT_GONE,
60
+ LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
61
+ LEG_STATE_ACTIVE,
62
+ LEG_STATE_ARMED,
63
+ LEG_STATE_CANCEL_TENTATIVE,
64
+ LEG_STATE_CASCADED_CANCEL,
65
+ LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
66
+ LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
67
+ LEG_STATE_LIVE,
68
+ LEG_STATE_PENDING_ENTRY,
69
+ LEG_STATE_TRIGGERED,
70
+ LEG_STATE_TRIGGERED_FAILED,
71
+ LEG_STATE_TRIGGERED_UNKNOWN,
72
+ LEG_STATE_TRIGGERING,
73
+ iter_active_engine_trigger_partial_legs,
74
+ update_engine_trigger_partial_leg_state,
75
+ )
76
+
77
+ if TYPE_CHECKING:
78
+ from pynecore.core.broker.models import ExchangePosition
79
+ from pynecore.core.broker.storage import OrderRow, RunContext
80
+
81
+
82
+ __all__ = [
83
+ 'PartialBracketLeg',
84
+ 'PartialBracketSafetyVerdict',
85
+ 'SoftwarePartialBracketEngine',
86
+ 'TickOffsetResolver',
87
+ ]
88
+
89
+
90
+ # Composite key uniquely identifying a leg in the in-memory ledger.
91
+ # The third element discriminates between concurrent legs sharing the
92
+ # same (pine_id, from_entry) — a tp_partial / sl_partial / trail_partial
93
+ # trio attached to one ``strategy.exit(...)``.
94
+ LegKey = tuple[str, str, str]
95
+
96
+
97
+ @dataclass
98
+ class PartialBracketLeg:
99
+ """In-memory mirror of one engine-trigger partial bracket leg row.
100
+
101
+ Mirrors the canonical extras keys in :mod:`store_helpers`; the
102
+ persistent representation is the authoritative one (the engine
103
+ reloads this struct from there on restart). Field names match
104
+ the ``EXTRAS_KEY_*`` constants so a future refactor that flips
105
+ the storage layout (e.g. dedicated SQL columns) is a one-place
106
+ rename.
107
+
108
+ ``side`` is the CLOSE side — opposite of the parent direction —
109
+ because the leg's purpose is to dispatch a partial close, not a
110
+ fresh open.
111
+ """
112
+ coid: str
113
+ symbol: str
114
+ pine_id: str
115
+ from_entry: str
116
+ leg_kind: str
117
+ leg_state: str
118
+ side: str
119
+ qty: float
120
+ intent_key: str
121
+ parent_pine_entry_id: str
122
+ parent_entry_dispatch_ref: str
123
+ intent_partial_qty: float
124
+ trigger_level: float | None = None
125
+ trigger_offset: float | None = None
126
+ # Trail-only fields. ``trail_activation_level`` is the absolute price
127
+ # the engine waits for before the trailing stop activates: while it
128
+ # is non-``None`` the leg is in its pre-activation phase and
129
+ # :meth:`SoftwarePartialBracketEngine._is_triggered` always returns
130
+ # ``False``. The moment the WATCH-phase price crosses this level,
131
+ # the engine clears the field and seeds ``trigger_level`` with the
132
+ # initial moving stop (``activation - offset`` for a long parent,
133
+ # ``activation + offset`` for a short). ``trail_activation_offset``
134
+ # carries the *pre*-fill distance (price units) for trail legs
135
+ # whose parent entry is still pending — the parent-fill handler
136
+ # resolves it to an absolute activation level.
137
+ trail_activation_level: float | None = None
138
+ trail_activation_offset: float | None = None
139
+ oca_group: str | None = None
140
+ oca_type: str | None = None
141
+ extras: dict = field(default_factory=dict)
142
+
143
+ @property
144
+ def key(self) -> LegKey:
145
+ return self.pine_id, self.from_entry, self.leg_kind
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class PartialBracketSafetyVerdict:
150
+ """Outcome of the §2.4 safety check at trigger time.
151
+
152
+ ``ok=True`` means the live parent snapshot still authorises the
153
+ close: the position is on the expected side and has enough open
154
+ quantity to reduce by ``effective_close_qty`` without flipping
155
+ or opening. ``ok=False`` means the engine must NOT dispatch a
156
+ close — the parent vanished or reversed under us.
157
+ """
158
+ ok: bool
159
+ reason: str
160
+ effective_close_qty: float
161
+
162
+
163
+ # Sign of the parent side, normalised to +1 (long) / -1 (short) — used
164
+ # by trigger comparisons. ``unknown`` is a guard for pending legs whose
165
+ # parent direction has not been observed yet.
166
+ _SIDE_LONG = 'long'
167
+ _SIDE_SHORT = 'short'
168
+
169
+
170
+ def _parent_sign(side: str) -> int | None:
171
+ if side == _SIDE_LONG:
172
+ return 1
173
+ if side == _SIDE_SHORT:
174
+ return -1
175
+ return None
176
+
177
+
178
+ def _close_side_for_parent(parent_side: str) -> str:
179
+ """The CLOSE order side for a given parent direction."""
180
+ if parent_side == _SIDE_LONG:
181
+ return 'sell'
182
+ if parent_side == _SIDE_SHORT:
183
+ return 'buy'
184
+ raise ValueError(f"unexpected parent side: {parent_side!r}")
185
+
186
+
187
+ class SoftwarePartialBracketEngine:
188
+ """In-memory state machine for engine-trigger partial brackets.
189
+
190
+ One instance per :class:`OrderSyncEngine`. The engine does not
191
+ talk to the broker directly; the sync engine performs every
192
+ broker-side action (close dispatch, audit emission, safety
193
+ snapshot) on the state machine's behalf via dedicated hooks
194
+ that will be wired in Slice B. This separation keeps the state
195
+ machine deterministic and unit-testable in isolation.
196
+
197
+ The legacy bracket leg row format (the ``tp`` / ``sl`` legs the
198
+ journal already manages for native brackets) is NOT touched by
199
+ this state machine — those rows remain owned by the plugin's
200
+ bracket lifecycle. The engine-trigger leg rows use the
201
+ dedicated :data:`~pynecore.core.broker.store_helpers.STATE_PARTIAL_BRACKET_LEG`
202
+ marker so the two never collide.
203
+ """
204
+
205
+ def __init__(
206
+ self,
207
+ store_ctx: 'RunContext | None',
208
+ *,
209
+ state_change_listener: 'Callable[[PartialBracketLeg, str | None, str], None] | None' = None,
210
+ ) -> None:
211
+ self._store_ctx = store_ctx
212
+ # Listener fired after every leg-state mutation (register / transition).
213
+ # The sync engine wires this to drive the §2.6 worst-SL recompute
214
+ # so the broker-native fail-safe state machine sees every armed /
215
+ # triggered / cancelled SL leg without coupling the partial-bracket
216
+ # engine to the failsafe manager directly.
217
+ self._state_change_listener = state_change_listener
218
+ self._legs: dict[LegKey, PartialBracketLeg] = {}
219
+ # Membership index for the OCA cascade. ``oca_group`` is the
220
+ # private partial-exit group name the dispatch helper mints
221
+ # (``__partial_exit_{pine_id}_{from_entry}__``); the cascade
222
+ # walks all members of one group when any of them triggers.
223
+ self._legs_by_oca_group: dict[str, set[LegKey]] = {}
224
+ # Membership index keyed by Pine parent entry id, for the
225
+ # parent-gone / native-SL cascade paths that target every leg
226
+ # attached to one parent regardless of OCA grouping.
227
+ self._legs_by_parent: dict[tuple[str, str], set[LegKey]] = {}
228
+
229
+ # === Registration =====================================================
230
+
231
+ def register_leg(self, leg: PartialBracketLeg) -> None:
232
+ """Add one freshly-persisted leg to the in-memory ledger.
233
+
234
+ Called by the sync engine after a successful
235
+ :func:`~pynecore.core.broker.store_helpers.create_engine_trigger_partial_leg_row`.
236
+ The leg must already be in :data:`LEG_STATE_ACTIVE` — terminal
237
+ rows are rejected because they have no business in the ledger.
238
+ """
239
+ if leg.leg_state not in LEG_STATE_ACTIVE:
240
+ raise ValueError(
241
+ f"register_leg: refuses to track leg in non-active state "
242
+ f"{leg.leg_state!r} (key={leg.key!r})"
243
+ )
244
+ if leg.key in self._legs:
245
+ raise ValueError(
246
+ f"register_leg: leg already tracked (key={leg.key!r}, "
247
+ f"existing coid={self._legs[leg.key].coid!r}, "
248
+ f"new coid={leg.coid!r})"
249
+ )
250
+ self._legs[leg.key] = leg
251
+ if leg.oca_group is not None:
252
+ self._legs_by_oca_group.setdefault(leg.oca_group, set()).add(leg.key)
253
+ self._legs_by_parent.setdefault(
254
+ (leg.symbol, leg.from_entry), set(),
255
+ ).add(leg.key)
256
+ if self._state_change_listener is not None:
257
+ self._state_change_listener(leg, None, leg.leg_state)
258
+
259
+ def register_legs(self, legs: Iterable[PartialBracketLeg]) -> None:
260
+ for leg in legs:
261
+ self.register_leg(leg)
262
+
263
+ # === Queries ==========================================================
264
+
265
+ def get_leg(self, key: LegKey) -> PartialBracketLeg | None:
266
+ return self._legs.get(key)
267
+
268
+ def iter_legs(self) -> Iterable[PartialBracketLeg]:
269
+ return list(self._legs.values())
270
+
271
+ def iter_legs_for_parent(
272
+ self, symbol: str, from_entry: str,
273
+ ) -> list[PartialBracketLeg]:
274
+ keys = self._legs_by_parent.get((symbol, from_entry), set())
275
+ return [self._legs[k] for k in keys if k in self._legs]
276
+
277
+ def has_active_partial_bracket(
278
+ self, symbol: str, from_entry: str,
279
+ ) -> bool:
280
+ """Whether any active engine-trigger partial leg exists for a parent.
281
+
282
+ Used by the sync engine's invariant guard at
283
+ ``_dispatch_engine_trigger_partial_bracket`` entry: a freshly
284
+ dispatched native full-row bracket on the same parent must
285
+ not coexist with engine-trigger partial legs (§12 #4).
286
+ """
287
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
288
+ if leg.leg_state in LEG_STATE_ACTIVE:
289
+ return True
290
+ return False
291
+
292
+ def has_active_legs_for_intent(self, intent_key: str) -> bool:
293
+ """Whether any active leg with the given ``intent_key`` is tracked.
294
+
295
+ Used by the dispatch-side guard to reject a *re-dispatch* of the
296
+ same :class:`~pynecore.core.broker.models.ExitIntent`. The check
297
+ is intent-scoped (not parent-scoped) because Pine permits
298
+ multiple scale-out exits under one parent — e.g. TP1 / TP2 with
299
+ different ``strategy.exit(id=...)`` values share the same
300
+ ``from_entry`` but have distinct ``intent_key`` values
301
+ (``(pine_id, from_entry)``). A parent-wide guard would block the
302
+ second scale-out as a "duplicate".
303
+ """
304
+ for leg in self._legs.values():
305
+ if leg.intent_key == intent_key \
306
+ and leg.leg_state in LEG_STATE_ACTIVE:
307
+ return True
308
+ return False
309
+
310
+ def has_sibling_active_legs(
311
+ self, symbol: str, from_entry: str, exclude_intent_key: str,
312
+ ) -> bool:
313
+ """Whether any active leg exists on a parent under a DIFFERENT intent_key.
314
+
315
+ Used by the partial-bracket modify preflight to detect the
316
+ scale-out sibling case: parent ``from_entry`` already carries
317
+ another scale-out exit (TP1 / TP2 under distinct ``pine_id`` →
318
+ distinct ``intent_key``) whose legs would survive a cancel of
319
+ ``exclude_intent_key``. The whole-row exit replacement issued
320
+ after that cancel would then hit the §12 #4 coexistence guard
321
+ in :meth:`OrderSyncEngine._dispatch_new`, so the caller must
322
+ refuse the modify before evicting the legs that would leave
323
+ the parent unprotected.
324
+ """
325
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
326
+ if leg.intent_key != exclude_intent_key \
327
+ and leg.leg_state in LEG_STATE_ACTIVE:
328
+ return True
329
+ return False
330
+
331
+ def cancel_legs_for_intent(
332
+ self, intent_key: str, *, reason: str,
333
+ ) -> list[PartialBracketLeg]:
334
+ """Cancel every active leg under one ``intent_key``.
335
+
336
+ Used by the sync engine when the strategy drops or replaces a
337
+ partial-bracket ``ExitIntent``: the leg row dispatch is
338
+ engine-internal, so the standard ``execute_cancel`` /
339
+ ``modify_exit`` broker calls do not apply. The cascade
340
+ transitions each leg to :data:`LEG_STATE_CASCADED_CANCEL`
341
+ with the supplied ``reason`` recorded in the row's audit
342
+ extras.
343
+ """
344
+ cancelled: list[PartialBracketLeg] = []
345
+ # Snapshot first; the transition mutates the ledger.
346
+ targets = [
347
+ leg for leg in self._legs.values()
348
+ if leg.intent_key == intent_key
349
+ and leg.leg_state in LEG_STATE_ACTIVE
350
+ ]
351
+ for leg in targets:
352
+ self._transition(
353
+ leg, LEG_STATE_CASCADED_CANCEL,
354
+ close_row=True,
355
+ extras_patch={'cascade_reason': reason},
356
+ )
357
+ cancelled.append(leg)
358
+ return cancelled
359
+
360
+ # === Pending → armed promotion =======================================
361
+
362
+ def on_parent_entry_filled(
363
+ self,
364
+ *,
365
+ symbol: str,
366
+ from_entry: str,
367
+ fill_price: float,
368
+ parent_side: str,
369
+ parent_qty: float,
370
+ resolver: 'TickOffsetResolver | None' = None,
371
+ ) -> list[PartialBracketLeg]:
372
+ """Promote ``pending_entry`` legs to ``armed`` on the parent fill.
373
+
374
+ Resolves ``trigger_offset`` (tick distance) into an absolute
375
+ ``trigger_level`` and updates the persisted row. The caller
376
+ is the sync engine's parent-fill handler; the ``resolver``
377
+ encodes the ``profit_ticks`` / ``loss_ticks`` / ``trail_points_ticks``
378
+ → price conversion using the symbol's ``mintick``.
379
+
380
+ Returns the legs that were promoted. The empty list is fine
381
+ (no pending legs for this parent).
382
+ """
383
+ promoted: list[PartialBracketLeg] = []
384
+ parent_sign = _parent_sign(parent_side)
385
+ if parent_sign is None:
386
+ return promoted
387
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
388
+ if leg.leg_state != LEG_STATE_PENDING_ENTRY:
389
+ continue
390
+ extras_patch: dict | None = None
391
+ level = leg.trigger_level
392
+ # For trail legs we resolve the *activation* level — the
393
+ # leg stays in pre-activation phase until WATCH observes
394
+ # the price crossing it. TP / SL legs have no activation
395
+ # concept: ``trigger_level`` is the final price the engine
396
+ # watches and the resolver fills it directly.
397
+ if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
398
+ activation = leg.trail_activation_level
399
+ if activation is None and leg.trail_activation_offset is not None:
400
+ activation = (
401
+ fill_price + parent_sign * leg.trail_activation_offset
402
+ )
403
+ if activation is None:
404
+ continue
405
+ leg.trail_activation_level = activation
406
+ extras_patch = {EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: activation}
407
+ else:
408
+ if level is None and resolver is not None:
409
+ level = resolver.resolve(leg, fill_price=fill_price,
410
+ parent_sign=parent_sign)
411
+ if level is None:
412
+ continue
413
+ leg.trigger_level = level
414
+ old_state = leg.leg_state
415
+ leg.leg_state = LEG_STATE_ARMED
416
+ if self._store_ctx is not None:
417
+ update_engine_trigger_partial_leg_state(
418
+ self._store_ctx,
419
+ coid=leg.coid,
420
+ new_leg_state=LEG_STATE_ARMED,
421
+ trigger_level=level,
422
+ extras_patch=extras_patch,
423
+ )
424
+ # Fire the state-change listener so the §2.6 worst-SL
425
+ # recompute picks up the just-armed leg's absolute SL.
426
+ # Without this notification, ``_recompute_native_failsafe_for_parent``
427
+ # only re-walks the legs when a *later* unrelated leg event
428
+ # mutates state — until then the leg is invisible to the
429
+ # broker-native failsafe even though it is fully armed.
430
+ if self._state_change_listener is not None:
431
+ self._state_change_listener(leg, old_state, LEG_STATE_ARMED)
432
+ promoted.append(leg)
433
+ _ = parent_qty # reserved for §2.4 future qty-bookkeeping
434
+ return promoted
435
+
436
+ # === WATCH phase ======================================================
437
+
438
+ def on_price_tick(
439
+ self,
440
+ *,
441
+ symbol: str,
442
+ last_price: float | None,
443
+ bid: float | None,
444
+ ask: float | None,
445
+ parent_snapshot: 'ExchangePosition | None',
446
+ ) -> list[PartialBracketLeg]:
447
+ """Run the WATCH-phase trigger check for all armed legs.
448
+
449
+ Returns the legs that crossed their trigger this tick and
450
+ passed the safety check — i.e. legs in :data:`LEG_STATE_TRIGGERING`
451
+ that the sync engine should now dispatch a partial
452
+ :class:`CloseIntent` for. The close dispatch itself is the
453
+ sync engine's responsibility (Slice B wiring); this method
454
+ owns only the state-machine bookkeeping.
455
+
456
+ :param symbol: Exchange symbol whose armed legs are checked this
457
+ tick; legs belonging to other symbols are skipped.
458
+ :param last_price: Last traded price. Used for trail recompute
459
+ and as the default trigger comparison source.
460
+ :param bid: Best bid; long-side TP and short-side SL compare
461
+ against this once the §12 #2 quote source is resolved.
462
+ :param ask: Best ask; short-side TP and long-side SL compare
463
+ against this.
464
+ :param parent_snapshot: Live position snapshot for the safety
465
+ check. ``None`` means the engine could not refresh the
466
+ snapshot — every trigger is suppressed this tick (the
467
+ machine waits for a snapshot rather than firing blind).
468
+ """
469
+ triggering: list[PartialBracketLeg] = []
470
+ if parent_snapshot is None:
471
+ return triggering
472
+ # Same-tick reservation: when more than one armed leg under the
473
+ # same (symbol, from_entry) crosses on the same tick, each call
474
+ # to :meth:`_safety_check` would otherwise cap against the
475
+ # unchanged ``parent.size``. The loop decrements the remaining
476
+ # parent size as legs are accepted so two 0.75 legs on a 1.0
477
+ # position cannot collectively dispatch 1.5 and flip the parent.
478
+ remaining_by_parent: dict[tuple[str, str], float] = {}
479
+ # OCA groups that already produced a triggering leg in *this*
480
+ # tick. Used to skip incompatible siblings (e.g. TP + SL crossing
481
+ # together on a gap) without cancelling them: cancelling SL/trail
482
+ # legs synchronously here would fire the §2.6 worst-SL recompute
483
+ # listener and drop the broker-native stop *before* the
484
+ # triggered TP's CLOSE has been accepted/filled. If the close
485
+ # fails, times out, or the process crashes between detection and
486
+ # dispatch, the parent would be left with no software *and* no
487
+ # native protection. Keep the siblings ``armed`` instead; the
488
+ # cascade cancel runs later, on the ``triggering → triggered``
489
+ # transition once the close is confirmed (Slice B dispatch
490
+ # wiring) or on the parent-flat observation.
491
+ reserved_oca_groups: set[str] = set()
492
+ # Cross-tick reservation: a sibling already in ``triggering`` /
493
+ # ``triggered_failed`` / ``triggered_unknown`` from an earlier
494
+ # tick still has an in-flight close — the cascade cancel runs
495
+ # only on the ``triggering → triggered`` confirmation. Until
496
+ # then, another armed sibling in the same OCA group must not be
497
+ # allowed to cross and dispatch a second close (e.g. TP close
498
+ # pending, then price reverses into the SL/trail level on the
499
+ # next tick): that would over-reduce the parent before the
500
+ # first close resolves. Seed the reservation set so the
501
+ # per-iteration skip below covers in-flight siblings too.
502
+ for leg in self._legs.values():
503
+ if leg.symbol != symbol:
504
+ continue
505
+ if leg.oca_group is None:
506
+ continue
507
+ if leg.leg_state in (
508
+ LEG_STATE_TRIGGERING,
509
+ LEG_STATE_TRIGGERED_FAILED,
510
+ LEG_STATE_TRIGGERED_UNKNOWN,
511
+ ):
512
+ reserved_oca_groups.add(leg.oca_group)
513
+ for leg in list(self._legs.values()):
514
+ if leg.symbol != symbol:
515
+ continue
516
+ if leg.leg_state != LEG_STATE_ARMED:
517
+ continue
518
+ if (leg.oca_group is not None
519
+ and leg.oca_group in reserved_oca_groups):
520
+ # Sibling in the same OCA group already crossed this
521
+ # tick; skip without cancelling so SL protection stays
522
+ # in place until the winning leg's close is confirmed.
523
+ continue
524
+ # Trail legs are armed *before* activation and intentionally
525
+ # carry ``trigger_level=None`` until the price crosses
526
+ # ``trail_activation_level``. Run :meth:`_maybe_advance_trail`
527
+ # first so a pre-activation tick can seed the initial stop;
528
+ # only after that does a missing ``trigger_level`` mean the
529
+ # leg has nothing to compare against this tick (still
530
+ # pre-activation, or non-trail leg without a resolved level).
531
+ if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
532
+ self._maybe_advance_trail(leg, last_price)
533
+ if leg.trigger_level is None:
534
+ continue
535
+ if not self._is_triggered(leg, last_price=last_price,
536
+ bid=bid, ask=ask):
537
+ continue
538
+ parent_key = (leg.symbol, leg.from_entry)
539
+ remaining = remaining_by_parent.get(parent_key, parent_snapshot.size)
540
+ verdict = self._safety_check(leg, parent_snapshot,
541
+ remaining_size=remaining)
542
+ if not verdict.ok:
543
+ self._transition(
544
+ leg, LEG_STATE_ABORTED_PARENT_GONE,
545
+ close_row=True,
546
+ extras_patch={'safety_abort_reason': verdict.reason},
547
+ )
548
+ continue
549
+ remaining_by_parent[parent_key] = max(
550
+ 0.0, remaining - verdict.effective_close_qty,
551
+ )
552
+ self._transition(leg, LEG_STATE_TRIGGERING, close_row=False,
553
+ qty=verdict.effective_close_qty)
554
+ triggering.append(leg)
555
+ if leg.oca_group is not None:
556
+ reserved_oca_groups.add(leg.oca_group)
557
+ return triggering
558
+
559
+ # noinspection PyMethodMayBeStatic
560
+ def _is_triggered(
561
+ self,
562
+ leg: PartialBracketLeg,
563
+ *,
564
+ last_price: float | None,
565
+ bid: float | None,
566
+ ask: float | None,
567
+ ) -> bool:
568
+ """Whether the current quote crosses the leg's trigger level.
569
+
570
+ The bid/ask vs. last-price selection is intentionally simple
571
+ in Slice A and defers to ``last_price`` whenever it is
572
+ available; §12 #2 will refine the choice once the WS quote
573
+ feed semantics are validated in production. The comparison
574
+ directions:
575
+
576
+ - Long parent, TP leg: trigger when price >= level.
577
+ - Long parent, SL / trail leg: trigger when price <= level.
578
+ - Short parent, TP leg: trigger when price <= level.
579
+ - Short parent, SL / trail leg: trigger when price >= level.
580
+
581
+ Trail legs whose ``trail_activation_level`` is still set
582
+ return ``False`` here — the leg is armed but the trailing
583
+ stop has not yet "activated" so the trigger comparison is
584
+ meaningless. Activation itself is handled by
585
+ :meth:`_maybe_advance_trail`, which clears the activation
586
+ field and seeds ``trigger_level`` once the price crosses the
587
+ activation threshold.
588
+ """
589
+ if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL \
590
+ and leg.trail_activation_level is not None:
591
+ return False
592
+ if leg.trigger_level is None:
593
+ return False
594
+ price = last_price if last_price is not None else (
595
+ bid if bid is not None else ask
596
+ )
597
+ if price is None:
598
+ return False
599
+ parent_long = leg.side == 'sell'
600
+ is_take_profit = leg.leg_kind == LEG_KIND_TP_PARTIAL
601
+ if parent_long:
602
+ return (price >= leg.trigger_level) if is_take_profit \
603
+ else (price <= leg.trigger_level)
604
+ return (price <= leg.trigger_level) if is_take_profit \
605
+ else (price >= leg.trigger_level)
606
+
607
+ def _maybe_advance_trail(
608
+ self,
609
+ leg: PartialBracketLeg,
610
+ last_price: float | None,
611
+ ) -> None:
612
+ """Drive a trailing-stop leg through activation and the trail itself.
613
+
614
+ Two-phase contract:
615
+
616
+ 1. **Pre-activation** (``trail_activation_level`` set): the
617
+ method waits for ``last_price`` to cross the activation
618
+ threshold in the favourable direction (>= for a long parent,
619
+ <= for a short). When it does, it clears
620
+ ``trail_activation_level`` and seeds ``trigger_level`` with
621
+ the initial moving stop (``activation - offset`` long /
622
+ ``activation + offset`` short). The leg now enters the
623
+ normal trail phase and the next price tick may already
624
+ trigger via :meth:`_is_triggered`.
625
+ 2. **Active trail** (``trail_activation_level`` is ``None``):
626
+ the trigger level only moves in the favourable direction —
627
+ a long parent trail can only rise, a short can only fall.
628
+
629
+ Both transitions persist the new ``trigger_level`` /
630
+ ``trail_activation_level`` so a restart mid-trail does not lose
631
+ the high-water mark or the activation state.
632
+ """
633
+ if leg.leg_kind != LEG_KIND_TRAIL_PARTIAL:
634
+ return
635
+ if last_price is None or leg.trigger_offset is None:
636
+ return
637
+ parent_long = leg.side == 'sell'
638
+ if leg.trail_activation_level is not None:
639
+ # Pre-activation: wait for the activation threshold.
640
+ activation = leg.trail_activation_level
641
+ activated = (last_price >= activation) if parent_long \
642
+ else (last_price <= activation)
643
+ if not activated:
644
+ return
645
+ # Seed the initial stop from the current favourable price,
646
+ # not from the activation threshold. When the tick jumps
647
+ # past activation (e.g. activation=100, last_price=110,
648
+ # offset=5 on a long), the initial stop must be
649
+ # ``last_price - offset = 105`` so the trail starts as
650
+ # tight as the offset prescribes. Anchoring on activation
651
+ # would leave the stop at 95 — too loose by the entire
652
+ # over-shoot — until another favourable tick arrived.
653
+ initial_stop = (last_price - leg.trigger_offset) if parent_long \
654
+ else (last_price + leg.trigger_offset)
655
+ leg.trail_activation_level = None
656
+ leg.trigger_level = initial_stop
657
+ if self._store_ctx is not None:
658
+ update_engine_trigger_partial_leg_state(
659
+ self._store_ctx,
660
+ coid=leg.coid,
661
+ new_leg_state=leg.leg_state,
662
+ trigger_level=initial_stop,
663
+ extras_patch={EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: None},
664
+ )
665
+ if self._state_change_listener is not None:
666
+ # No state transition — but the trail leg's worst-SL
667
+ # contribution just changed (pre-activation watch level
668
+ # → post-activation trailing stop). Fire so the §2.6
669
+ # failsafe manager recomputes with the new level.
670
+ self._state_change_listener(leg, leg.leg_state, leg.leg_state)
671
+ return
672
+ candidate = (last_price - leg.trigger_offset) if parent_long \
673
+ else (last_price + leg.trigger_offset)
674
+ current = leg.trigger_level
675
+ if current is None:
676
+ new_level = candidate
677
+ elif parent_long and candidate > current:
678
+ new_level = candidate
679
+ elif (not parent_long) and candidate < current:
680
+ new_level = candidate
681
+ else:
682
+ return
683
+ leg.trigger_level = new_level
684
+ if self._store_ctx is not None:
685
+ update_engine_trigger_partial_leg_state(
686
+ self._store_ctx,
687
+ coid=leg.coid,
688
+ new_leg_state=leg.leg_state,
689
+ trigger_level=new_level,
690
+ )
691
+ if self._state_change_listener is not None:
692
+ # Trail leg moved its trigger_level while staying armed —
693
+ # the §2.6 failsafe manager needs the new contribution.
694
+ self._state_change_listener(leg, leg.leg_state, leg.leg_state)
695
+
696
+ # noinspection PyMethodMayBeStatic
697
+ def _safety_check(
698
+ self,
699
+ leg: PartialBracketLeg,
700
+ parent: 'ExchangePosition',
701
+ *,
702
+ remaining_size: float | None = None,
703
+ ) -> PartialBracketSafetyVerdict:
704
+ """§2.4 invariant: parent on the expected side and large enough.
705
+
706
+ The leg's recorded ``side`` is the CLOSE side: ``'sell'`` means
707
+ the leg closes a long parent. If the live snapshot disagrees
708
+ (parent flat, parent on the other side), the leg must abort —
709
+ firing the close now could reopen a position in the wrong
710
+ direction. The qty cap prevents over-reduction when the parent
711
+ was partially closed by a different actor.
712
+
713
+ :param remaining_size: When supplied, caps ``effective_close_qty``
714
+ against this value instead of the raw ``parent.size``. The
715
+ same-tick caller decrements the remaining parent size as
716
+ sibling legs are accepted so two crossings on one tick cannot
717
+ collectively close more than the live parent holds.
718
+ """
719
+ if parent.side == 'flat' or parent.size <= 0.0:
720
+ return PartialBracketSafetyVerdict(
721
+ ok=False,
722
+ reason='parent_flat',
723
+ effective_close_qty=0.0,
724
+ )
725
+ expected_parent_side = _SIDE_LONG if leg.side == 'sell' else _SIDE_SHORT
726
+ if parent.side != expected_parent_side:
727
+ return PartialBracketSafetyVerdict(
728
+ ok=False,
729
+ reason='parent_reversed',
730
+ effective_close_qty=0.0,
731
+ )
732
+ size_cap = parent.size if remaining_size is None \
733
+ else min(parent.size, remaining_size)
734
+ # ``leg.qty`` is the per-leg cap that survives the row's lifecycle:
735
+ # at first arming it equals ``intent_partial_qty``, but the
736
+ # same-tick sibling cascade in :meth:`on_price_tick` rewrites it
737
+ # via :meth:`_transition` with the smaller ``effective_close_qty``
738
+ # before the close dispatches. If the process crashed between the
739
+ # cap write and the close fill, :meth:`restart_replay` demotes the
740
+ # leg back to ``armed`` but keeps the persisted capped value;
741
+ # falling back to ``intent_partial_qty`` here would let the retry
742
+ # close the full original size and over-reduce a parent that was
743
+ # already partially closed by the prior cap's settled fill.
744
+ desired_qty = min(leg.qty, leg.intent_partial_qty) \
745
+ if leg.qty > 0.0 else leg.intent_partial_qty
746
+ effective_qty = min(desired_qty, size_cap)
747
+ if effective_qty <= 0.0:
748
+ return PartialBracketSafetyVerdict(
749
+ ok=False,
750
+ reason='zero_effective_qty',
751
+ effective_close_qty=0.0,
752
+ )
753
+ return PartialBracketSafetyVerdict(
754
+ ok=True,
755
+ reason='ok',
756
+ effective_close_qty=effective_qty,
757
+ )
758
+
759
+ # === Close-dispatch settling ==========================================
760
+
761
+ def confirm_trigger_dispatched(
762
+ self,
763
+ leg_key: LegKey,
764
+ *,
765
+ close_pine_id: str,
766
+ ) -> list[PartialBracketLeg]:
767
+ """Settle a :data:`LEG_STATE_TRIGGERING` leg as fired.
768
+
769
+ Called by the sync engine immediately after the synthetic
770
+ :class:`CloseIntent` for ``leg`` has been dispatched to the
771
+ broker plugin. The leg moves to
772
+ :data:`LEG_STATE_TRIGGERED` (terminal, row closed) and the
773
+ OCA cascade fires for sibling legs sharing the same
774
+ :attr:`PartialBracketLeg.oca_group` with
775
+ :attr:`OcaType.CANCEL` semantics. ``close_pine_id`` is
776
+ the synthesised exit id stamped on the close envelope so the
777
+ cascade audit row can correlate the two.
778
+
779
+ :return: List of OCA siblings that were cascaded by this
780
+ settlement; empty if the leg has no OCA group or no
781
+ cancellable sibling. The triggered leg itself is not
782
+ included.
783
+ """
784
+ leg = self._legs.get(leg_key)
785
+ if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
786
+ return []
787
+ # Cascade FIRST while the triggered leg is still in the ledger —
788
+ # :meth:`cascade_cancel_oca` resolves the OCA group via
789
+ # ``self._legs[triggered_key]``, so a prior eviction would silently
790
+ # drop the cascade. The triggered leg's own state stays
791
+ # ``triggering`` for this call (siblings already in a terminal
792
+ # state are skipped by the cascade), and the final transition
793
+ # below flips it to :data:`LEG_STATE_TRIGGERED` and closes the row.
794
+ cascaded = self.cascade_cancel_oca(leg_key)
795
+ self._transition(
796
+ leg, LEG_STATE_TRIGGERED,
797
+ close_row=True,
798
+ extras_patch={'close_pine_id': close_pine_id},
799
+ )
800
+ return cascaded
801
+
802
+ def mark_trigger_dispatch_failed(
803
+ self,
804
+ leg_key: LegKey,
805
+ *,
806
+ reason: str,
807
+ ) -> None:
808
+ """Settle a :data:`LEG_STATE_TRIGGERING` leg as a hard failure.
809
+
810
+ Used when the close dispatch was rejected with an error the
811
+ engine cannot reasonably retry on its own (e.g.
812
+ :class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`
813
+ or a non-recoverable broker error that is NOT a network
814
+ ambiguity). The leg lands briefly in
815
+ :data:`LEG_STATE_TRIGGERED_FAILED` for the audit row, then
816
+ demotes back to :data:`LEG_STATE_ARMED` so the next price
817
+ tick re-evaluates against a fresh parent snapshot. Idempotent
818
+ no-op when the leg is not in :data:`LEG_STATE_TRIGGERING`.
819
+ """
820
+ leg = self._legs.get(leg_key)
821
+ if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
822
+ return
823
+ self._transition(
824
+ leg, LEG_STATE_TRIGGERED_FAILED,
825
+ close_row=False,
826
+ extras_patch={'trigger_failed_reason': reason},
827
+ )
828
+ self._transition(
829
+ leg, LEG_STATE_ARMED,
830
+ close_row=False,
831
+ )
832
+
833
+ def mark_trigger_dispatch_unknown(
834
+ self,
835
+ leg_key: LegKey,
836
+ *,
837
+ reason: str,
838
+ ) -> None:
839
+ """Settle a :data:`LEG_STATE_TRIGGERING` leg as a parked dispatch.
840
+
841
+ Used when the close dispatch raised
842
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
843
+ — the broker may or may not have accepted the order. The leg
844
+ lands briefly in :data:`LEG_STATE_TRIGGERED_UNKNOWN` for the
845
+ audit row, then demotes back to :data:`LEG_STATE_ARMED` so the
846
+ next price tick re-evaluates the trigger. The sync engine's
847
+ regular parked-dispatch resolution path (``_verify_pending_dispatches``)
848
+ will reconcile any in-flight close that did in fact land — the
849
+ re-armed leg's :meth:`_safety_check` caps against the live
850
+ parent size and avoids over-closing if the prior attempt
851
+ already reduced the position. Idempotent no-op when the leg
852
+ is not in :data:`LEG_STATE_TRIGGERING`.
853
+ """
854
+ leg = self._legs.get(leg_key)
855
+ if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
856
+ return
857
+ self._transition(
858
+ leg, LEG_STATE_TRIGGERED_UNKNOWN,
859
+ close_row=False,
860
+ extras_patch={'trigger_unknown_reason': reason},
861
+ )
862
+ self._transition(
863
+ leg, LEG_STATE_ARMED,
864
+ close_row=False,
865
+ )
866
+
867
+ # === OCA cascade ======================================================
868
+
869
+ def cascade_cancel_oca(
870
+ self,
871
+ triggered_key: LegKey,
872
+ *,
873
+ reason: str = 'oca_sibling_triggered',
874
+ ) -> list[PartialBracketLeg]:
875
+ """Cancel every armed sibling sharing the triggered leg's OCA group.
876
+
877
+ Only :data:`OcaType.CANCEL` groups cascade — ``reduce`` and
878
+ ``none`` groups leave their siblings alone, matching the
879
+ sync-engine OCA path (:meth:`OrderSyncEngine._cascade_oca_cancel`)
880
+ which gates on ``oca_type == OcaType.CANCEL.value``. Keying solely
881
+ on ``oca_group`` here would otherwise tear down unrelated
882
+ ``reduce``/``none`` siblings as soon as one leg triggers, dropping
883
+ partial-exit protection the script intentionally kept independent.
884
+
885
+ The triggered leg itself is left alone (the caller flips it to
886
+ :data:`~pynecore.core.broker.store_helpers.LEG_STATE_TRIGGERED`
887
+ after the close fill). Siblings already in a terminal state
888
+ are skipped.
889
+ """
890
+ triggered = self._legs.get(triggered_key)
891
+ if triggered is None or triggered.oca_group is None:
892
+ return []
893
+ if triggered.oca_type != OcaType.CANCEL.value:
894
+ return []
895
+ cancelled: list[PartialBracketLeg] = []
896
+ for key in list(self._legs_by_oca_group.get(triggered.oca_group, ())):
897
+ if key == triggered_key:
898
+ continue
899
+ sibling = self._legs.get(key)
900
+ if sibling is None or sibling.leg_state not in LEG_STATE_ACTIVE:
901
+ continue
902
+ if sibling.oca_type != OcaType.CANCEL.value:
903
+ # Mixed-type groupings are degenerate (a single oca_group
904
+ # name should carry one consistent oca_type), but skip
905
+ # defensively rather than cancel a ``reduce``/``none``
906
+ # sibling that the script wanted to keep alive.
907
+ continue
908
+ self._transition(
909
+ sibling, LEG_STATE_CASCADED_CANCEL,
910
+ close_row=True,
911
+ extras_patch={'cascade_reason': reason},
912
+ )
913
+ cancelled.append(sibling)
914
+ return cancelled
915
+
916
+ def cascade_cancel_by_parent_close(
917
+ self,
918
+ *,
919
+ symbol: str,
920
+ from_entry: str,
921
+ reason: str = 'parent_closed',
922
+ ) -> list[PartialBracketLeg]:
923
+ """Cancel every active leg under a parent that just flattened."""
924
+ cancelled: list[PartialBracketLeg] = []
925
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
926
+ if leg.leg_state not in LEG_STATE_ACTIVE:
927
+ continue
928
+ self._transition(
929
+ leg, LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
930
+ close_row=True,
931
+ extras_patch={'cascade_reason': reason},
932
+ )
933
+ cancelled.append(leg)
934
+ return cancelled
935
+
936
+ def cascade_cancel_by_native_sl(
937
+ self,
938
+ *,
939
+ symbol: str,
940
+ from_entry: str,
941
+ sl_level: float,
942
+ ) -> list[PartialBracketLeg]:
943
+ """§3.4: a broker-native fail-safe SL hit replaces every active leg.
944
+
945
+ The snapshot-driven reconcile in the sync engine observes
946
+ the position vanishing; every remaining engine-trigger leg
947
+ (intermediate SL, TP, trail) goes to
948
+ :data:`~pynecore.core.broker.store_helpers.LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL`
949
+ with the SL level recorded for audit.
950
+ """
951
+ cancelled: list[PartialBracketLeg] = []
952
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
953
+ if leg.leg_state not in LEG_STATE_ACTIVE:
954
+ continue
955
+ self._transition(
956
+ leg, LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
957
+ close_row=True,
958
+ extras_patch={
959
+ 'cascade_reason': 'native_sl_hit',
960
+ 'native_sl_level': sl_level,
961
+ },
962
+ )
963
+ cancelled.append(leg)
964
+ return cancelled
965
+
966
+ def abort_pending_legs_for_parent_never_arrived(
967
+ self,
968
+ *,
969
+ symbol: str,
970
+ from_entry: str,
971
+ reason: str,
972
+ ) -> list[PartialBracketLeg]:
973
+ """§3.2 cleanup: pending entry never filled (rejected / cancelled / expired).
974
+
975
+ Only ``pending_entry`` legs are affected — already-armed legs
976
+ belong to a different lifecycle path and would have aborted
977
+ via the standard parent-gone cascade.
978
+ """
979
+ cleaned: list[PartialBracketLeg] = []
980
+ for leg in self.iter_legs_for_parent(symbol, from_entry):
981
+ if leg.leg_state != LEG_STATE_PENDING_ENTRY:
982
+ continue
983
+ self._transition(
984
+ leg, LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
985
+ close_row=True,
986
+ extras_patch={'abort_reason': reason},
987
+ )
988
+ cleaned.append(leg)
989
+ return cleaned
990
+
991
+ # === Cancel-tentative state machine ===================================
992
+
993
+ def mark_legs_cancel_tentative(
994
+ self,
995
+ parent_from_entry: str,
996
+ *,
997
+ reason: str,
998
+ now_ms: int,
999
+ ) -> list[PartialBracketLeg]:
1000
+ """Flip every ``pending_entry`` leg of one parent to
1001
+ ``cancel_tentative``.
1002
+
1003
+ Called by the sync engine's swallowed-unknown branch of
1004
+ ``_dispatch_cancel`` when ``execute_cancel`` raises
1005
+ :class:`OrderDispositionUnknownError` on a parent entry. The
1006
+ legs stay live (the row's ``closed_ts_ms`` is not set) and are
1007
+ excluded from worst-SL contribution / price-tick arming until
1008
+ either the ``reconcile()`` cancel-retry-loop resolves the
1009
+ disposition (→ :meth:`confirm_cancel_tentative` or
1010
+ :meth:`restore_legs_from_cancel_tentative`) or the stale-grace
1011
+ deadline expires (→ ``DEGRADED_HALT`` by the caller).
1012
+
1013
+ Already ``armed`` legs are NOT affected: per the dossier's leg-
1014
+ trio atomicity invariant (:meth:`on_parent_entry_filled` flips
1015
+ the whole trio atomically), ``ARMED + PENDING_ENTRY`` coexistence
1016
+ under a single parent is structurally impossible while the parent
1017
+ is still pending.
1018
+
1019
+ :param parent_from_entry: The parent ``EntryIntent.intent_key``
1020
+ (≡ Pine ``from_entry`` on every child :class:`ExitIntent`).
1021
+ :param reason: Audit reason recorded on the leg row's extras.
1022
+ :param now_ms: Wall-clock timestamp (ms) marking entry into
1023
+ the cancel-tentative state. Persisted under
1024
+ :data:`EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS`; survives
1025
+ restart so the stale-grace deadline can be rehydrated.
1026
+ :return: List of legs that were transitioned.
1027
+ """
1028
+ flipped: list[PartialBracketLeg] = []
1029
+ targets = [
1030
+ leg for leg in self._legs.values()
1031
+ if leg.from_entry == parent_from_entry
1032
+ and leg.leg_state == LEG_STATE_PENDING_ENTRY
1033
+ ]
1034
+ for leg in targets:
1035
+ self._transition(
1036
+ leg, LEG_STATE_CANCEL_TENTATIVE,
1037
+ close_row=False,
1038
+ extras_patch={
1039
+ EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: now_ms,
1040
+ 'cancel_tentative_reason': reason,
1041
+ },
1042
+ )
1043
+ flipped.append(leg)
1044
+ return flipped
1045
+
1046
+ def restore_legs_from_cancel_tentative(
1047
+ self,
1048
+ parent_from_entry: str,
1049
+ *,
1050
+ parent_filled: bool,
1051
+ reason: str,
1052
+ ) -> list[PartialBracketLeg]:
1053
+ """Reverse :meth:`mark_legs_cancel_tentative` for one parent.
1054
+
1055
+ Called when the cancel-retry-loop or a late parent FILL event
1056
+ proves the parent is in fact alive (the cancel attempt that
1057
+ timed out never actually landed). Legs return to
1058
+ :data:`LEG_STATE_PENDING_ENTRY` when the parent is still pending
1059
+ (no fill event observed yet) or directly to
1060
+ :data:`LEG_STATE_ARMED` when the parent has filled
1061
+ (``parent_filled=True``). In the armed case the caller is
1062
+ responsible for re-registering the parent with the
1063
+ ``NativeFailsafeManager``.
1064
+
1065
+ Idempotent: when no leg is in cancel-tentative for the parent
1066
+ (e.g. because both the event-driven path and the reconcile-retry
1067
+ path delivered the resolution on the same tick), the second
1068
+ call is a no-op.
1069
+
1070
+ :param parent_from_entry: The parent ``EntryIntent.intent_key``
1071
+ whose tentative legs are being restored.
1072
+ :param parent_filled: When ``True``, restore to ``armed``
1073
+ (the parent is filled, the leg should resume worst-SL
1074
+ contribution and tick processing). When ``False``,
1075
+ restore to ``pending_entry`` (parent still pending; the
1076
+ next :meth:`on_parent_entry_filled` will promote).
1077
+ :param reason: Audit reason recorded on the leg row's extras.
1078
+ :return: List of legs that were transitioned. Empty if no
1079
+ tentative leg matched the parent (idempotent no-op).
1080
+ """
1081
+ target_state = (
1082
+ LEG_STATE_ARMED if parent_filled else LEG_STATE_PENDING_ENTRY
1083
+ )
1084
+ restored: list[PartialBracketLeg] = []
1085
+ targets = [
1086
+ leg for leg in self._legs.values()
1087
+ if leg.from_entry == parent_from_entry
1088
+ and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
1089
+ ]
1090
+ for leg in targets:
1091
+ self._transition(
1092
+ leg, target_state,
1093
+ close_row=False,
1094
+ extras_patch={
1095
+ EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: None,
1096
+ 'cancel_tentative_resolved_reason': reason,
1097
+ },
1098
+ )
1099
+ restored.append(leg)
1100
+ return restored
1101
+
1102
+ def confirm_cancel_tentative(
1103
+ self,
1104
+ parent_from_entry: str,
1105
+ *,
1106
+ reason: str,
1107
+ ) -> list[PartialBracketLeg]:
1108
+ """Resolve :meth:`mark_legs_cancel_tentative` forward for one parent.
1109
+
1110
+ Called when the cancel-retry-loop receives a
1111
+ :attr:`CancelDispositionOutcome.CANCEL_CONFIRMED`,
1112
+ :attr:`CancelDispositionOutcome.STILL_OPEN`, or
1113
+ :attr:`CancelDispositionOutcome.TOO_LATE_TO_CANCEL` outcome,
1114
+ or when a parent CANCELLED order event arrives for a parent
1115
+ currently in cancel-tentative. The tentative legs are flipped
1116
+ to :data:`LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED` (terminal)
1117
+ and their rows are closed.
1118
+
1119
+ Idempotent: if no tentative leg matches the parent (because
1120
+ a previous call already terminated them), this is a no-op.
1121
+
1122
+ :param parent_from_entry: The parent ``EntryIntent.intent_key``
1123
+ whose tentative legs are being confirmed-cancelled.
1124
+ :param reason: Audit reason recorded on the leg row's extras.
1125
+ :return: List of legs that were transitioned. Empty if no
1126
+ tentative leg matched (idempotent no-op).
1127
+ """
1128
+ confirmed: list[PartialBracketLeg] = []
1129
+ targets = [
1130
+ leg for leg in self._legs.values()
1131
+ if leg.from_entry == parent_from_entry
1132
+ and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
1133
+ ]
1134
+ for leg in targets:
1135
+ self._transition(
1136
+ leg, LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
1137
+ close_row=True,
1138
+ extras_patch={
1139
+ 'abort_reason': reason,
1140
+ EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: None,
1141
+ },
1142
+ )
1143
+ confirmed.append(leg)
1144
+ return confirmed
1145
+
1146
+ def iter_cancel_tentative_parents(self) -> set[str]:
1147
+ """Snapshot of every distinct parent ``from_entry`` that currently
1148
+ has at least one leg in :data:`LEG_STATE_CANCEL_TENTATIVE`.
1149
+
1150
+ Used by the sync engine's ``reconcile()`` cancel-retry-loop to
1151
+ decide which parents still need disposition resolution. The
1152
+ result is a set (not a list) so the caller can intersect /
1153
+ difference against its shadow map without worrying about leg-
1154
+ trio multiplicity.
1155
+ """
1156
+ return {
1157
+ leg.from_entry for leg in self._legs.values()
1158
+ if leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
1159
+ }
1160
+
1161
+ def has_cancel_tentative_legs(self, parent_from_entry: str) -> bool:
1162
+ """Whether any leg under ``parent_from_entry`` is in
1163
+ :data:`LEG_STATE_CANCEL_TENTATIVE`.
1164
+
1165
+ Quick check used by the sync engine's diff-loop refuse-and-defer
1166
+ guard before the adoption branch — symmetric to
1167
+ :meth:`has_active_legs_for_intent` but for the verification state
1168
+ and parent-scoped (not intent-scoped).
1169
+ """
1170
+ for leg in self._legs.values():
1171
+ if leg.from_entry == parent_from_entry \
1172
+ and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE:
1173
+ return True
1174
+ return False
1175
+
1176
+ # === Restart replay ===================================================
1177
+
1178
+ def restart_replay(self) -> None:
1179
+ """Rebuild the in-memory ledger from persisted leg rows.
1180
+
1181
+ Called once by the sync engine during startup, after the
1182
+ journal's regular replay but before the first :meth:`sync`.
1183
+ Terminal rows are filtered out by
1184
+ :func:`iter_active_engine_trigger_partial_legs`; active rows
1185
+ in ``triggering`` / ``triggered_failed`` / ``triggered_unknown``
1186
+ are reloaded as-is and the next :meth:`on_price_tick` re-
1187
+ evaluates them (the sync engine's reconciliation path
1188
+ decides whether the prior trigger landed or needs retry).
1189
+
1190
+ Stale-price recovery (§3.5): when this method observes an
1191
+ ``armed`` leg whose ``trigger_level`` has already been crossed
1192
+ by the current spot quote, it does NOT fire on the spot —
1193
+ firing belongs to :meth:`on_price_tick` once the sync engine
1194
+ supplies a fresh parent snapshot. The stale-trigger policy
1195
+ choice (conservative / strict, see §12 #3) lives in the sync
1196
+ engine, not here.
1197
+
1198
+ In-flight state recovery: legs persisted in
1199
+ :data:`LEG_STATE_TRIGGERING`,
1200
+ :data:`LEG_STATE_TRIGGERED_FAILED` or
1201
+ :data:`LEG_STATE_TRIGGERED_UNKNOWN` are intermediate — the
1202
+ process crashed between trigger detection and the close
1203
+ dispatch settling. :meth:`on_price_tick` only advances
1204
+ ``armed`` legs, and no other code path acts on those
1205
+ intermediate states, so they would remain stuck across the
1206
+ restart. Demote them back to ``armed`` here so the next tick
1207
+ re-evaluates the trigger against a fresh parent snapshot;
1208
+ if the prior close already landed at the broker, the parent
1209
+ size will have shrunk and the safety check will cap or abort
1210
+ the re-fire accordingly.
1211
+ """
1212
+ if self._store_ctx is None:
1213
+ return
1214
+ self._legs.clear()
1215
+ self._legs_by_oca_group.clear()
1216
+ self._legs_by_parent.clear()
1217
+ for row in iter_active_engine_trigger_partial_legs(self._store_ctx):
1218
+ leg = _leg_from_row(row)
1219
+ if leg is None:
1220
+ continue
1221
+ self._legs[leg.key] = leg
1222
+ if leg.oca_group is not None:
1223
+ self._legs_by_oca_group.setdefault(
1224
+ leg.oca_group, set(),
1225
+ ).add(leg.key)
1226
+ self._legs_by_parent.setdefault(
1227
+ (leg.symbol, leg.from_entry), set(),
1228
+ ).add(leg.key)
1229
+ if leg.leg_state in (
1230
+ LEG_STATE_TRIGGERING,
1231
+ LEG_STATE_TRIGGERED_FAILED,
1232
+ LEG_STATE_TRIGGERED_UNKNOWN,
1233
+ ):
1234
+ self._transition(
1235
+ leg, LEG_STATE_ARMED,
1236
+ close_row=False,
1237
+ extras_patch={'rearmed_after_restart_from': leg.leg_state},
1238
+ )
1239
+
1240
+ # === State machine plumbing ==========================================
1241
+
1242
+ def _transition(
1243
+ self,
1244
+ leg: PartialBracketLeg,
1245
+ new_state: str,
1246
+ *,
1247
+ close_row: bool,
1248
+ extras_patch: dict | None = None,
1249
+ qty: float | None = None,
1250
+ ) -> None:
1251
+ old_state = leg.leg_state
1252
+ leg.leg_state = new_state
1253
+ if qty is not None:
1254
+ leg.qty = qty
1255
+ if extras_patch:
1256
+ leg.extras.update(extras_patch)
1257
+ if self._store_ctx is not None:
1258
+ update_engine_trigger_partial_leg_state(
1259
+ self._store_ctx,
1260
+ coid=leg.coid,
1261
+ new_leg_state=new_state,
1262
+ qty=qty,
1263
+ extras_patch=extras_patch,
1264
+ close_row=close_row,
1265
+ )
1266
+ if self._state_change_listener is not None:
1267
+ self._state_change_listener(leg, old_state, new_state)
1268
+ if close_row:
1269
+ self._evict(leg.key)
1270
+
1271
+ def _evict(self, key: LegKey) -> None:
1272
+ leg = self._legs.pop(key, None)
1273
+ if leg is None:
1274
+ return
1275
+ if leg.oca_group is not None:
1276
+ group = self._legs_by_oca_group.get(leg.oca_group)
1277
+ if group is not None:
1278
+ group.discard(key)
1279
+ if not group:
1280
+ self._legs_by_oca_group.pop(leg.oca_group, None)
1281
+ parent_key = (leg.symbol, leg.from_entry)
1282
+ parent_group = self._legs_by_parent.get(parent_key)
1283
+ if parent_group is not None:
1284
+ parent_group.discard(key)
1285
+ if not parent_group:
1286
+ self._legs_by_parent.pop(parent_key, None)
1287
+
1288
+
1289
+ # === Helpers ==============================================================
1290
+
1291
+ class TickOffsetResolver:
1292
+ """Convert a leg's ``trigger_offset`` (price units) into an absolute
1293
+ price level at parent-fill time.
1294
+
1295
+ Slice A keeps the resolver protocol thin — the sync engine
1296
+ converts raw tick fields (``profit_ticks`` / ``loss_ticks`` /
1297
+ ``trail_points_ticks``) to price units at dispatch time before
1298
+ storing them in ``trigger_offset``, so the resolver itself just
1299
+ applies the offset to the fill price with the correct sign for
1300
+ the leg kind. The ``mintick`` parameter is kept on the resolver
1301
+ for forward-compat with a future call site that does the
1302
+ conversion here.
1303
+ """
1304
+
1305
+ def __init__(self, mintick: float) -> None:
1306
+ self._mintick = mintick
1307
+
1308
+ # noinspection PyMethodMayBeStatic
1309
+ def resolve(
1310
+ self,
1311
+ leg: PartialBracketLeg,
1312
+ *,
1313
+ fill_price: float,
1314
+ parent_sign: int,
1315
+ ) -> float | None:
1316
+ if leg.trigger_offset is None:
1317
+ return None
1318
+ if leg.leg_kind == LEG_KIND_TP_PARTIAL:
1319
+ return fill_price + parent_sign * leg.trigger_offset
1320
+ if leg.leg_kind == LEG_KIND_SL_PARTIAL:
1321
+ return fill_price - parent_sign * leg.trigger_offset
1322
+ if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
1323
+ return fill_price - parent_sign * leg.trigger_offset
1324
+ return None
1325
+
1326
+
1327
+ def _leg_from_row(row: 'OrderRow') -> PartialBracketLeg | None:
1328
+ extras = row.extras or {}
1329
+ leg_kind = extras.get(EXTRAS_KEY_LEG_KIND, '')
1330
+ leg_state = extras.get(EXTRAS_KEY_LEG_STATE, '')
1331
+ if leg_kind not in (
1332
+ LEG_KIND_TP_PARTIAL, LEG_KIND_SL_PARTIAL, LEG_KIND_TRAIL_PARTIAL,
1333
+ ):
1334
+ return None
1335
+ # Accept every live state, including ``cancel_tentative``. The
1336
+ # tentative row is still open (``closed_ts_ms IS NULL``) and the
1337
+ # sync engine's cancel-retry loop drives its next transition — the
1338
+ # in-memory ledger must therefore carry it so
1339
+ # :meth:`SoftwarePartialBracketEngine.iter_cancel_tentative_parents`
1340
+ # and the post-restart
1341
+ # :meth:`OrderSyncEngine._rehydrate_cancel_tentative_from_replayed_legs`
1342
+ # rehydrate can see it. The ``restart_replay`` only re-arms the three
1343
+ # in-flight engine-owned states (``triggering`` / ``triggered_failed``
1344
+ # / ``triggered_unknown``), so a tentative row loaded here stays
1345
+ # tentative until the cancel-retry loop resolves it.
1346
+ if leg_state not in LEG_STATE_LIVE:
1347
+ return None
1348
+ parent_pine_entry_id = extras.get(EXTRAS_KEY_PARENT_PINE_ENTRY_ID, '')
1349
+ parent_entry_dispatch_ref = extras.get(
1350
+ EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF, '',
1351
+ )
1352
+ intent_partial_qty = float(extras.get(EXTRAS_KEY_INTENT_PARTIAL_QTY, 0.0))
1353
+ trigger_level = extras.get(EXTRAS_KEY_TRIGGER_LEVEL)
1354
+ trigger_offset = extras.get(EXTRAS_KEY_TRIGGER_OFFSET)
1355
+ trail_activation_level = extras.get(EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL)
1356
+ trail_activation_offset = extras.get(EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET)
1357
+ pine_id = row.pine_entry_id or ''
1358
+ from_entry = row.from_entry or ''
1359
+ return PartialBracketLeg(
1360
+ coid=row.client_order_id,
1361
+ symbol=row.symbol,
1362
+ pine_id=pine_id,
1363
+ from_entry=from_entry,
1364
+ leg_kind=leg_kind,
1365
+ leg_state=leg_state,
1366
+ side=row.side,
1367
+ qty=row.qty,
1368
+ intent_key=row.intent_key or '',
1369
+ parent_pine_entry_id=parent_pine_entry_id,
1370
+ parent_entry_dispatch_ref=parent_entry_dispatch_ref,
1371
+ intent_partial_qty=intent_partial_qty,
1372
+ trigger_level=trigger_level,
1373
+ trigger_offset=trigger_offset,
1374
+ trail_activation_level=trail_activation_level,
1375
+ trail_activation_offset=trail_activation_offset,
1376
+ oca_group=extras.get(EXTRAS_KEY_OCA_GROUP),
1377
+ oca_type=extras.get(EXTRAS_KEY_OCA_TYPE),
1378
+ extras=dict(extras),
1379
+ )