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,1436 @@
1
+ """
2
+ Broker-native fail-safe worst-SL manager (§2.6 + §2.6.7).
3
+
4
+ The SOFTWARE partial-qty-bracket path keeps the engine in charge of
5
+ every intermediate TP / SL / trailing leg, which means the parent
6
+ position is *unprotected* whenever the engine itself is offline (process
7
+ crash, network split, WS staleness). The §2.6 fail-safe complements the
8
+ engine layer with a single broker-native ``stopLevel`` on the parent
9
+ ``dealId`` — the *worst* SL across the active partial legs — so the
10
+ downtime exposure is bounded.
11
+
12
+ This module owns the engine-side bookkeeping for that safety net. It does
13
+ **not** issue the actual ``PUT /positions/{dealId}`` itself; the plugin
14
+ keeps the wire format and the broker-side mapping from
15
+ ``parent_entry_dispatch_ref`` (a stable client-side COID) to ``dealId``.
16
+ The manager produces:
17
+
18
+ - a per-parent ``NativeStopState`` machine that tracks
19
+ ``healthy → degrading → degraded → retired`` transitions (§2.6.7);
20
+ - the *desired* :class:`NativeBracketSnapshot` to send on every PUT (full
21
+ replacement, never patch — Capital.com PUT semantics per §9 #19 Exp D2);
22
+ - block / allow decisions for new partial brackets and new
23
+ ``strategy.entry`` signals while the failsafe is not healthy.
24
+
25
+ Time enters via an explicit ``now_ms`` argument; the manager itself does
26
+ not call ``time.time()`` so unit tests and replay paths get deterministic
27
+ clocks. Retry pacing is **event-driven**: the manager records the failed
28
+ PUT and exposes :meth:`legs_wanting_retry` for the engine's poll tick to
29
+ re-dispatch — never a ``sleep`` (``feedback_no_sleep``).
30
+ """
31
+ from dataclasses import dataclass, field
32
+ from enum import StrEnum
33
+ from typing import Callable, Iterable
34
+
35
+ from pynecore.core.broker.models import (
36
+ BrokerEvent,
37
+ BrokerNativeFailsafeExternalEditEvent,
38
+ BrokerNativeFailsafeUnavailableEvent,
39
+ EntryBlockedDegradedFailsafeEvent,
40
+ EntrySkippedDueToDegradedFailsafeEvent,
41
+ NativeFailsafeStateTransitionEvent,
42
+ PartialBracketBlockedDegradedFailsafeEvent,
43
+ )
44
+
45
+
46
+ __all__ = [
47
+ 'FailsafeHealth',
48
+ 'FailsafeOwner',
49
+ 'NativeBracketSnapshot',
50
+ 'OutstandingLevel',
51
+ 'NativeStopState',
52
+ 'NativeFailsafeManager',
53
+ 'TRAIL_COALESCE_WINDOW_MS_DEFAULT',
54
+ 'PUT_MAX_ATTEMPTS_DEFAULT',
55
+ 'STALE_WINDOW_MS_DEFAULT',
56
+ 'TRAIL_STEP_THRESHOLD_TICKS_DEFAULT',
57
+ ]
58
+
59
+
60
+ # === Defaults (config-tunable later) =====================================
61
+
62
+ TRAIL_COALESCE_WINDOW_MS_DEFAULT: float = 250.0
63
+ """§2.6.5 trailing PUT coalesce window. Trail moves within this slice are
64
+ collapsed into the latest desired level; the PUT fires once at the end."""
65
+
66
+ TRAIL_STEP_THRESHOLD_TICKS_DEFAULT: float = 1.0
67
+ """§2.6.5 step-distance threshold expressed in ticks. Trail moves smaller
68
+ than this are not dispatched (sub-tick noise / broker ``minStepDistance``)."""
69
+
70
+ PUT_MAX_ATTEMPTS_DEFAULT: int = 3
71
+ """§2.6.7 immediate retry budget before the state transitions to
72
+ ``degrading``. Each ``record_put_failure`` call counts as one attempt."""
73
+
74
+ STALE_WINDOW_MS_DEFAULT: float = 30_000.0
75
+ """§2.6.7 stale window. ``degrading`` states that do not confirm
76
+ ``actual_level == desired_level`` within this window flip to ``degraded``.
77
+
78
+ Also the §2.6.7 confirmation-timeout window: a ``healthy`` state whose
79
+ dispatched levels are never reflected back by a reconcile snapshot within
80
+ this window flips to ``degraded`` (reason ``confirmation-timeout``). Same
81
+ business question — "how long may we run without verified broker
82
+ protection" — so the same default value is reused."""
83
+
84
+ _OUTSTANDING_LEVELS_CAP: int = 256
85
+ """Hard backstop on the per-parent outstanding-levels list length. The list
86
+ only grows via genuine new-desired dispatches (never retries), and the
87
+ confirmation-timeout window freezes growth once it expires (``degraded`` makes
88
+ ``recompute_worst_sl`` a no-op), so in practice the cap is never reached — it
89
+ exists purely to bound memory under a pathological, never-confirming churn."""
90
+
91
+
92
+ # === Public types ========================================================
93
+
94
+ class FailsafeHealth(StrEnum):
95
+ """``NativeStopState`` health (§2.6.7)."""
96
+ HEALTHY = 'healthy'
97
+ DEGRADING = 'degrading'
98
+ DEGRADED = 'degraded'
99
+ RETIRED = 'retired'
100
+
101
+
102
+ class FailsafeOwner(StrEnum):
103
+ """Who currently owns the broker-native bracket on this parent."""
104
+ ENGINE_FAILSAFE = 'engine-failsafe'
105
+ USER_NATIVE = 'user-native'
106
+ UNKNOWN = 'unknown'
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class NativeBracketSnapshot:
111
+ """Full desired bracket state to send on a single PUT (§2.6.6).
112
+
113
+ The Capital.com ``PUT /positions/{dealId}`` is *full replacement* — any
114
+ bracket field omitted from the body is *deleted*. The engine therefore
115
+ carries the desired ``profit_level`` and ``trailing_stop`` alongside
116
+ ``stop_level`` so that worst-SL recompute updates do not accidentally
117
+ erase coexisting TP or trailing legs that the plugin previously
118
+ attached.
119
+ """
120
+ parent_entry_dispatch_ref: str
121
+ symbol: str
122
+ parent_side: str # 'long' | 'short'
123
+ stop_level: float | None
124
+ profit_level: float | None
125
+ trailing_stop: float | None
126
+ generation: int
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class OutstandingLevel:
131
+ """One bracket triple the broker may legitimately be showing right now.
132
+
133
+ Capital.com ``PUT /positions/{dealId}`` returns no request-correlation
134
+ token, so a PUT is confirmed *only* by level-matching the next
135
+ ``GET /positions`` snapshot against a desired snapshot. When the desired
136
+ level moves more than once inside a single reconcile round-trip
137
+ (``X → 90 → 85``, every PUT acked but none yet observed back), a lagging
138
+ poll can report the intermediate ``90`` — which matches neither the latest
139
+ desired ``85`` nor the pre-PUT baseline ``X``. A single scalar baseline
140
+ slot therefore misreads that legitimately-stale observation as an external
141
+ edit and strands the parent (``owner=UNKNOWN``).
142
+
143
+ The fix tracks *every* dispatched level (plus the broker baseline as the
144
+ oldest entry) as a list of these triples, so any one of them exempts a
145
+ lagging observation. ``generation`` is the dispatch generation that
146
+ produced the level; ``dispatch_ts_ms`` is when it was queued.
147
+ """
148
+ sl: float | None
149
+ profit_level: float | None
150
+ trailing_stop: float | None
151
+ generation: int
152
+ dispatch_ts_ms: float
153
+
154
+
155
+ @dataclass
156
+ class NativeStopState:
157
+ """Per-parent fail-safe stop state machine (§2.6.7).
158
+
159
+ The ``generation`` counter increments every time the *desired* snapshot
160
+ changes; correlation back from a Capital.com PUT response is not
161
+ possible (no request token in the success body), so generation is
162
+ purely local anti-stale bookkeeping for the audit journal.
163
+ """
164
+ parent_entry_dispatch_ref: str
165
+ symbol: str
166
+ parent_side: str # 'long' | 'short'
167
+ desired_level: float | None = None
168
+ desired_profit_level: float | None = None
169
+ desired_trailing_stop: float | None = None
170
+ actual_level: float | None = None
171
+ actual_profit_level: float | None = None
172
+ actual_trailing_stop: float | None = None
173
+ owner: FailsafeOwner = FailsafeOwner.ENGINE_FAILSAFE
174
+ health: FailsafeHealth = FailsafeHealth.HEALTHY
175
+ generation: int = 0
176
+ immediate_attempts_remaining: int = PUT_MAX_ATTEMPTS_DEFAULT
177
+ last_put_ts_ms: float | None = None
178
+ last_failure_ts_ms: float | None = None
179
+ last_failure_reason: str | None = None
180
+ last_confirm_ts_ms: float | None = None
181
+ last_desired_change_ts_ms: float | None = None
182
+ # Anchor for the §2.6.7 stale-window timer. Set to the failure
183
+ # timestamp on the HEALTHY → DEGRADING transition; cleared on
184
+ # DEGRADING → HEALTHY recovery. Stays frozen across subsequent
185
+ # retry failures so the stale window measures "time since the
186
+ # failsafe started failing", not "time since the most recent
187
+ # failure".
188
+ degrading_since_ts_ms: float | None = None
189
+ last_trail_dispatch_ts_ms: float | None = None
190
+ last_trail_dispatched_level: float | None = None
191
+ # §2.6.5 trail-flush gating. ``last_desired_change_ts_ms`` is set on
192
+ # every desired-level change including immediate lifecycle PUTs, so it
193
+ # cannot be used by :meth:`flush_coalesced_trails` to tell "a trail
194
+ # update was suppressed and is waiting" from "the lifecycle path just
195
+ # dispatched". This dedicated marker is set ONLY when
196
+ # :meth:`recompute_worst_sl` actually throttles a trail move into the
197
+ # coalesce window; the flush clears it once the queued snapshot ships
198
+ # (or when an immediate lifecycle / trail dispatch overtakes the
199
+ # suppressed level). Without it, a confirmed-lifecycle PUT would be
200
+ # followed by a phantom duplicate flush once the coalesce window
201
+ # elapsed, bumping the generation and consuming retry budget on a
202
+ # transient failure.
203
+ pending_trail_change_ts_ms: float | None = None
204
+ pending_put: bool = False
205
+ pending_retry: bool = False
206
+ # Every bracket triple the broker may legitimately be showing while one or
207
+ # more freshly dispatched PUTs are still unconfirmed (§2.6.7). The oldest
208
+ # entry is the broker baseline captured at batch start (what the broker
209
+ # carried before the first in-flight PUT); each subsequent entry is a level
210
+ # an actual dispatch queued. A reconcile observation that equals ANY entry
211
+ # is a legitimately stale sample — not an external edit — so it does not
212
+ # flip ownership to UNKNOWN. A single scalar slot could only remember one
213
+ # such level and misread the *intermediate* level of a ``X → 90 → 85``
214
+ # multi-move-in-flight burst as a manual edit, stranding a correctly
215
+ # protected parent. See :class:`OutstandingLevel`.
216
+ #
217
+ # The list is pruned on every confirming observation (entries older than the
218
+ # matched generation can no longer be carried) and cleared outright when the
219
+ # broker confirms the latest desired triple or an external edit is detected.
220
+ outstanding: list[OutstandingLevel] = field(default_factory=list)
221
+ # Batch-start timestamp: set when ``outstanding`` goes empty → non-empty and
222
+ # left frozen across subsequent dispatches; reset to ``None`` only when the
223
+ # list is fully cleared (latest-desired confirm or external edit). This is
224
+ # the §2.6.7 confirmation-timeout anchor — it measures "how long has the
225
+ # current unconfirmed batch been outstanding". Anchoring on the *newest*
226
+ # dispatch would let churn (a strategy that re-dispatches faster than the
227
+ # broker confirms) slide the deadline forever; anchoring on the global
228
+ # ``last_confirm_ts_ms`` would instead punish a long-idle parent the instant
229
+ # it dispatches again. Batch-start is immune to both.
230
+ outstanding_since_ts_ms: float | None = None
231
+ # Has at least one PUT for the current outstanding batch been *acknowledged*
232
+ # by the broker? ``recompute_worst_sl`` arms ``outstanding`` (and the
233
+ # confirmation-timeout anchor) at *queue* time, before any dispatch, and
234
+ # :meth:`mark_dispatch_in_flight` hands the PUT to the dispatcher — but
235
+ # neither proves the broker actually stored the stop. The confirmation-timeout
236
+ # window must only arm once a PUT round-trip is ACKNOWLEDGED (the engine's
237
+ # :meth:`record_put_success`), because that is the first moment an
238
+ # acked-but-unconfirmed broker stop can exist. Gating on hand-off instead
239
+ # would arm the window for a *failed* PUT (rate-limit / reject): with retries
240
+ # still budgeted the state is legitimately HEALTHY, yet a later
241
+ # :meth:`tick_stale_window` would promote it straight to DEGRADED and drop the
242
+ # queued retry, bypassing the retry budget. Failed PUTs are governed solely by
243
+ # the retry budget → DEGRADING → DEGRADED machinery, never by this flag. In a
244
+ # state-only run (``set_native_bracket_dispatcher`` never called — unit tests,
245
+ # plugin not yet opted in) no PUT is ever sent or acked, so the timeout never
246
+ # fires. Set ``True`` in :meth:`record_put_success` and reset with the batch
247
+ # in :meth:`_clear_outstanding`.
248
+ batch_put_acked: bool = False
249
+ # Reason tag for a ``degraded`` state, used to scope automatic recovery. A
250
+ # PUT-failure ``degraded`` (``None``) requires an explicit user reset
251
+ # (§2.6.7). A confirmation-timeout ``degraded`` may instead recover to
252
+ # ``healthy`` on its own when a later reconcile snapshot finally confirms the
253
+ # latest desired triple — the broker protection is verified present again.
254
+ degraded_reason: str | None = None
255
+ # Empirical mintick for the symbol — needed by the trail step gate.
256
+ mintick: float = 0.0
257
+ # ``minmove`` / ``pricescale`` reconstruct the mintick grid with the same
258
+ # integer math as Pine ``math.round_to_mintick`` (mintick == minmove /
259
+ # pricescale), so engine-computed worst-SL floats are snapped to the exact
260
+ # tick grid the broker stores its stop on before they are compared in
261
+ # :meth:`on_native_bracket_observed`. ``minmove`` is not always integral
262
+ # (e.g. mintick 0.025 yields minmove 2.5, pricescale 100). Both default to
263
+ # the "grid unknown" sentinel (``0``); :meth:`_round_to_tick` is a no-op
264
+ # until the engine passes real symbol values via :meth:`register_parent`.
265
+ minmove: float = 0.0
266
+ pricescale: int = 0
267
+ # Stale-window override (None = use manager default).
268
+ stale_window_ms: float | None = None
269
+ # SL leg-kind set tracked for `degrading → healthy` recovery telemetry.
270
+ last_active_sl_count: int = 0
271
+
272
+
273
+ # === Manager =============================================================
274
+
275
+ class NativeFailsafeManager:
276
+ """Engine-side coordinator for the §2.6 broker-native fail-safe stop.
277
+
278
+ The manager owns one :class:`NativeStopState` per
279
+ ``parent_entry_dispatch_ref``. The sync engine drives the lifecycle:
280
+
281
+ - on partial-bracket dispatch: :meth:`register_parent`,
282
+ then :meth:`recompute_worst_sl` to seed the desired snapshot;
283
+ - on each leg lifecycle event (arm / trigger / cancel / trail move):
284
+ :meth:`recompute_worst_sl` again — the manager diff-detects whether
285
+ a new PUT is needed and returns it via :meth:`pending_dispatch`;
286
+ - on PUT result: :meth:`record_put_success` / :meth:`record_put_failure`;
287
+ - on every reconcile snapshot: :meth:`on_native_bracket_observed` and
288
+ :meth:`on_deal_id_disappeared`;
289
+ - on Pine-side new partial bracket / new entry: :meth:`block_new_partial_bracket`
290
+ / :meth:`block_new_entry`.
291
+
292
+ The manager itself emits no PUT and uses no clock; the engine controls
293
+ timing through the ``now_ms`` arguments. The Slice B plugin wires its
294
+ own dispatcher into :attr:`dispatch_hook` to actually issue the PUT.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ *,
300
+ event_sink: Callable[[BrokerEvent], None] | None = None,
301
+ trail_coalesce_window_ms: float = TRAIL_COALESCE_WINDOW_MS_DEFAULT,
302
+ trail_step_threshold_ticks: float = TRAIL_STEP_THRESHOLD_TICKS_DEFAULT,
303
+ put_max_attempts: int = PUT_MAX_ATTEMPTS_DEFAULT,
304
+ stale_window_ms: float = STALE_WINDOW_MS_DEFAULT,
305
+ level_epsilon: float = 1e-9,
306
+ ) -> None:
307
+ self._event_sink = event_sink
308
+ self._trail_coalesce_window_ms = trail_coalesce_window_ms
309
+ self._trail_step_threshold_ticks = trail_step_threshold_ticks
310
+ self._put_max_attempts = put_max_attempts
311
+ self._stale_window_ms = stale_window_ms
312
+ self._eps = level_epsilon
313
+ self._states: dict[str, NativeStopState] = {}
314
+ # Pending desired snapshots ready to dispatch. The engine drains
315
+ # this on every sync tick and forwards to the plugin. Entries
316
+ # disappear on :meth:`record_put_success` / :meth:`record_put_failure`.
317
+ self._pending: dict[str, NativeBracketSnapshot] = {}
318
+
319
+ # --- Lifecycle ------------------------------------------------------
320
+
321
+ def register_parent(
322
+ self,
323
+ *,
324
+ parent_entry_dispatch_ref: str,
325
+ symbol: str,
326
+ parent_side: str,
327
+ mintick: float,
328
+ minmove: float = 0.0,
329
+ pricescale: int = 0,
330
+ initial_profit_level: float | None = None,
331
+ initial_trailing_stop: float | None = None,
332
+ stale_window_ms: float | None = None,
333
+ pending_confirmation: bool = False,
334
+ now_ms: float | None = None,
335
+ ) -> NativeStopState:
336
+ """Register a parent the engine is about to attach a partial bracket
337
+ to (§2.6.7 ownership flip).
338
+
339
+ Idempotent: subsequent calls for the same ref keep the existing
340
+ state but refresh the coexisting ``profit_level`` / ``trailing_stop``
341
+ desired snapshot fields (those can legitimately change when the
342
+ plugin re-establishes a different TP at attach time).
343
+
344
+ ``pending_confirmation=True`` is the restart-replay entry point:
345
+ the previous process owned a NativeStopState whose health/owner
346
+ were not persisted, so we cannot tell whether the broker-native
347
+ stop is still in place. Start the freshly created state in
348
+ ``DEGRADING`` with ``degrading_since_ts_ms=now_ms``; this blocks
349
+ ``block_new_entry`` / ``block_new_partial_bracket`` until a
350
+ snapshot from :meth:`on_native_bracket_observed` confirms the
351
+ stop is healthy, and lets the stale-window timer expire to
352
+ ``DEGRADED`` if no confirmation arrives — both prevent adding
353
+ exposure on an unknown protection state. ``recompute_worst_sl``
354
+ still runs (the worst-SL machinery is allowed under
355
+ ``DEGRADING``), so the first post-restart PUT re-attaches the
356
+ broker stop normally. The flag is ignored on idempotent re-calls
357
+ for the same ref (caller's first registration sets the policy).
358
+ """
359
+ if parent_side not in ('long', 'short'):
360
+ raise ValueError(f"parent_side must be 'long' or 'short', got {parent_side!r}")
361
+ state = self._states.get(parent_entry_dispatch_ref)
362
+ if state is None:
363
+ state = NativeStopState(
364
+ parent_entry_dispatch_ref=parent_entry_dispatch_ref,
365
+ symbol=symbol,
366
+ parent_side=parent_side,
367
+ mintick=mintick,
368
+ minmove=minmove,
369
+ pricescale=pricescale,
370
+ stale_window_ms=stale_window_ms,
371
+ )
372
+ if pending_confirmation:
373
+ state.health = FailsafeHealth.DEGRADING
374
+ state.degrading_since_ts_ms = now_ms
375
+ self._states[parent_entry_dispatch_ref] = state
376
+ # Always refresh the coexisting desired fields so the next PUT
377
+ # carries the full picture even if the plugin re-attached the TP.
378
+ # Snap to the tick grid so a later broker observation (also snapped in
379
+ # :meth:`on_native_bracket_observed`) confirms by exact compare rather
380
+ # than drifting by a sub-tick fraction.
381
+ state.desired_profit_level = self._round_to_tick(initial_profit_level, state)
382
+ state.desired_trailing_stop = self._round_to_tick(initial_trailing_stop, state)
383
+ return state
384
+
385
+ def unregister_parent(self, parent_entry_dispatch_ref: str) -> None:
386
+ """Drop the per-parent state outright (used by tests / explicit
387
+ teardown). Production code goes through :meth:`on_deal_id_disappeared`
388
+ which keeps a one-shot retired marker."""
389
+ self._states.pop(parent_entry_dispatch_ref, None)
390
+ self._pending.pop(parent_entry_dispatch_ref, None)
391
+
392
+ def get_state(self, parent_entry_dispatch_ref: str) -> NativeStopState | None:
393
+ return self._states.get(parent_entry_dispatch_ref)
394
+
395
+ def iter_states(self) -> Iterable[NativeStopState]:
396
+ return self._states.values()
397
+
398
+ # --- Worst-SL computation ------------------------------------------
399
+
400
+ def recompute_worst_sl(
401
+ self,
402
+ *,
403
+ parent_entry_dispatch_ref: str,
404
+ active_sl_levels: Iterable[float],
405
+ now_ms: float,
406
+ trigger_kind: str = 'lifecycle',
407
+ ) -> NativeBracketSnapshot | None:
408
+ """Recompute the worst-SL for one parent and queue a PUT if it
409
+ differs from the current desired snapshot.
410
+
411
+ ``trigger_kind`` is one of ``lifecycle`` (leg armed/triggered/cancelled)
412
+ and ``trail`` (trailing leg level moved). Trail moves are subject to
413
+ the §2.6.5 coalesce + step threshold; lifecycle moves dispatch
414
+ immediately.
415
+
416
+ Returns the queued snapshot when one was generated, else ``None``.
417
+ """
418
+ state = self._states.get(parent_entry_dispatch_ref)
419
+ if state is None:
420
+ return None
421
+ if state.health is FailsafeHealth.RETIRED:
422
+ return None
423
+ if state.owner is not FailsafeOwner.ENGINE_FAILSAFE:
424
+ # `user-native` and `unknown` ownership: the engine does NOT
425
+ # overwrite. Worst-SL recompute is a no-op for telemetry
426
+ # accuracy. Recovery requires explicit user action.
427
+ return None
428
+ if state.health is FailsafeHealth.DEGRADED:
429
+ # §2.6.7: once the stale window has expired and the state is
430
+ # ``DEGRADED``, the engine must not write the broker-native stop
431
+ # again until the user calls ``set_risk`` / ``reset_to_engine``.
432
+ # A leg cancellation or trailing-level move arriving in this
433
+ # window would otherwise re-queue a ``NativeBracketSnapshot`` and
434
+ # overwrite the stop the operator may already have edited
435
+ # manually. No dispatch happens here under any ``DEGRADED`` reason.
436
+ #
437
+ # A PUT-failure ``DEGRADED`` (``degraded_reason is None``) skips the
438
+ # recompute entirely — the broker may carry a manual operator edit
439
+ # the engine has no claim over, so even the in-memory ``desired``
440
+ # must stay frozen until ``reset_to_engine`` re-seeds it.
441
+ #
442
+ # A confirmation-timeout ``DEGRADED`` is different: ownership never
443
+ # left the engine (nobody edited the stop, the feed merely lagged),
444
+ # so the in-memory ``desired`` SHOULD keep tracking the current leg
445
+ # set even while dispatch stays frozen. Without this, a leg cancelled
446
+ # during the timeout leaves ``desired`` pinned at the now-obsolete
447
+ # level; a later reconcile observing the still-armed obsolete broker
448
+ # stop would then match that stale ``desired`` and auto-recover to
449
+ # ``HEALTHY`` (``on_native_bracket_observed``) — re-opening the symbol
450
+ # gate while a stop the strategy no longer wants stays armed at the
451
+ # broker, with no further leg event to clear it. Tracking ``desired``
452
+ # here makes the recovery match compare against the live intent, so a
453
+ # superseded broker level stays a mismatch and the state holds
454
+ # ``DEGRADED`` until ``reset_to_engine`` re-seeds and clears it. No
455
+ # generation bump / outstanding append / queue happens — those drive
456
+ # dispatch + confirmation, which the DEGRADED freeze forbids.
457
+ if state.degraded_reason == 'confirmation-timeout':
458
+ state.desired_level = self._worst_sl(state, active_sl_levels)
459
+ return None
460
+
461
+ # Snap the engine-computed worst-SL to the symbol's tick grid before it
462
+ # becomes the desired level (handled inside :meth:`_worst_sl`). The
463
+ # broker stores its stop on that grid, so an unrounded float would make
464
+ # every confirming observation mismatch by a sub-tick and falsely flip
465
+ # ownership / health. Snapping at source also keeps the derived
466
+ # ``outstanding`` baseline / ``last_trail_dispatched_level`` (both
467
+ # sourced from ``desired_level``) on the grid for free.
468
+ new_desired = self._worst_sl(state, active_sl_levels)
469
+
470
+ if self._levels_equal(new_desired, state.desired_level):
471
+ return None
472
+
473
+ if trigger_kind == 'trail' and not self._trail_should_dispatch(
474
+ state, new_desired, now_ms,
475
+ ):
476
+ # Coalesce window or step threshold blocks dispatch for now;
477
+ # the recompute still updates the in-memory desired so the
478
+ # next tick can compare against the latest level. The engine
479
+ # is expected to call :meth:`flush_coalesced_trails` after
480
+ # the coalesce window so the throttled PUT eventually fires.
481
+ state.desired_level = new_desired
482
+ state.last_desired_change_ts_ms = now_ms
483
+ # Mark a *trail* update as pending the coalesce flush. The
484
+ # generic ``last_desired_change_ts_ms`` cannot be used as the
485
+ # flush gate because the immediate-lifecycle path below also
486
+ # updates it; flushing on that signal alone would queue a
487
+ # duplicate PUT after every lifecycle dispatch.
488
+ state.pending_trail_change_ts_ms = now_ms
489
+ return None
490
+
491
+ # Capture the level the broker still carries before this PUT lands —
492
+ # only at the start of a fresh batch (``outstanding`` empty). The
493
+ # baseline is the OLD ``desired_level`` (what the broker is showing
494
+ # right now) plus the unchanged coexisting TP / trailing. A follow-up
495
+ # recompute arriving while earlier PUTs are still unconfirmed leaves the
496
+ # baseline intact and simply appends its own level below.
497
+ self._note_batch_start(state, sl=state.desired_level, now_ms=now_ms)
498
+ state.desired_level = new_desired
499
+ state.generation += 1
500
+ state.last_desired_change_ts_ms = now_ms
501
+ state.immediate_attempts_remaining = self._put_max_attempts
502
+ snapshot = self._build_snapshot(state)
503
+ self._pending[parent_entry_dispatch_ref] = snapshot
504
+ self._append_outstanding(state, snapshot, now_ms=now_ms)
505
+ if trigger_kind == 'trail':
506
+ state.last_trail_dispatched_level = new_desired
507
+ state.last_trail_dispatch_ts_ms = now_ms
508
+ # An immediate dispatch (lifecycle OR a trail that passed the
509
+ # throttle gate) supersedes any earlier coalesced trail snapshot
510
+ # — clear the marker so :meth:`flush_coalesced_trails` does not
511
+ # re-queue an obsolete duplicate after the coalesce window.
512
+ state.pending_trail_change_ts_ms = None
513
+ return snapshot
514
+
515
+ def flush_coalesced_trails(self, now_ms: float) -> list[NativeBracketSnapshot]:
516
+ """Release any trail-coalesced desired snapshots whose window has
517
+ expired (§2.6.5). Each released snapshot is queued for dispatch
518
+ and returned in the result list.
519
+ """
520
+ released: list[NativeBracketSnapshot] = []
521
+ for state in self._states.values():
522
+ if state.health is FailsafeHealth.RETIRED:
523
+ continue
524
+ if state.owner is not FailsafeOwner.ENGINE_FAILSAFE:
525
+ continue
526
+ if state.health is FailsafeHealth.DEGRADED:
527
+ # §2.6.7: DEGRADED requires user reset before the engine
528
+ # writes the broker-native stop again. Mirror the guard in
529
+ # :meth:`recompute_worst_sl` so a coalesced trail flush
530
+ # cannot bypass it.
531
+ continue
532
+ if state.parent_entry_dispatch_ref in self._pending:
533
+ continue # already queued / in flight
534
+ # Only flush when a trail update was actually throttled into
535
+ # the coalesce window. ``last_desired_change_ts_ms`` cannot
536
+ # be used here because the lifecycle path also updates it on
537
+ # every immediate dispatch — the flush would then queue a
538
+ # duplicate PUT carrying the just-dispatched lifecycle level.
539
+ if state.pending_trail_change_ts_ms is None:
540
+ continue
541
+ if self._levels_equal(state.desired_level, state.last_trail_dispatched_level):
542
+ state.pending_trail_change_ts_ms = None
543
+ continue
544
+ if (now_ms - state.pending_trail_change_ts_ms) < self._trail_coalesce_window_ms:
545
+ continue
546
+ # §2.6.5 step-threshold guard: when ``recompute_worst_sl`` was
547
+ # suppressed only because the new desired level was inside the
548
+ # ``trail_step_threshold_ticks`` band of the last dispatched
549
+ # level, ``state.desired_level`` was still updated. Without
550
+ # re-checking the threshold here the flush would queue that
551
+ # sub-threshold level once the coalesce window elapses,
552
+ # defeating the rate-limit / min-step guard the threshold is
553
+ # there to enforce. Mirror the dispatch check used at recompute
554
+ # time before queueing.
555
+ if (state.last_trail_dispatched_level is not None
556
+ and state.desired_level is not None):
557
+ step = abs(state.desired_level - state.last_trail_dispatched_level)
558
+ threshold = state.mintick * self._trail_step_threshold_ticks
559
+ # Round-at-source snaps every level onto the tick grid, so a
560
+ # genuine N-tick step is a difference of grid points that can
561
+ # land a sub-ULP below ``N * mintick`` (a 0.025-grid 1-tick move
562
+ # computes as 0.024999999999999994). Tolerate that with the same
563
+ # ``self._eps`` the manager uses for level equality, else a
564
+ # legitimate 1-tick trail tightening is dropped and the broker
565
+ # stop stays a tick too loose on a safety-critical stop.
566
+ if step < threshold - self._eps:
567
+ continue
568
+ # Capture the level the broker still carries before this coalesced
569
+ # PUT lands — the snapshot is built fresh from an empty ``_pending``
570
+ # (guard above), so this is a batch start and the broker baseline is
571
+ # the previously dispatched trail level. ``flush_coalesced_trails``
572
+ # clears ``pending_trail_change_ts_ms`` below, so the trail-coalesce
573
+ # exemption no longer covers a stale post-dispatch poll; the
574
+ # ``outstanding`` list carries that exemption instead, exactly as the
575
+ # immediate-recompute path does.
576
+ self._note_batch_start(
577
+ state, sl=state.last_trail_dispatched_level, now_ms=now_ms,
578
+ )
579
+ state.generation += 1
580
+ state.immediate_attempts_remaining = self._put_max_attempts
581
+ snapshot = self._build_snapshot(state)
582
+ self._pending[state.parent_entry_dispatch_ref] = snapshot
583
+ self._append_outstanding(state, snapshot, now_ms=now_ms)
584
+ state.last_trail_dispatched_level = state.desired_level
585
+ state.last_trail_dispatch_ts_ms = now_ms
586
+ # Suppressed trail change has now shipped — clear the marker
587
+ # so a follow-up flush tick does not re-enter for the same
588
+ # desired level.
589
+ state.pending_trail_change_ts_ms = None
590
+ released.append(snapshot)
591
+ return released
592
+
593
+ # --- Dispatch hand-off ---------------------------------------------
594
+
595
+ def pending_dispatch(self) -> list[NativeBracketSnapshot]:
596
+ """Snapshots queued for the engine to forward to the plugin.
597
+
598
+ The engine is expected to call :meth:`mark_dispatch_in_flight`
599
+ before actually issuing the PUT and then :meth:`record_put_success`
600
+ / :meth:`record_put_failure` once the result is known.
601
+ """
602
+ return list(self._pending.values())
603
+
604
+ def mark_dispatch_in_flight(
605
+ self, parent_entry_dispatch_ref: str, *, now_ms: float,
606
+ ) -> None:
607
+ state = self._states.get(parent_entry_dispatch_ref)
608
+ if state is None:
609
+ return
610
+ state.pending_put = True
611
+ state.last_put_ts_ms = now_ms
612
+ # NOTE: the confirmation-timeout window is NOT armed here. Hand-off is not
613
+ # acknowledgement — a PUT can still fail (rate-limit / reject) and stay
614
+ # within the retry budget. ``batch_put_acked`` is set only once the broker
615
+ # actually acks the round-trip (see :meth:`record_put_success`).
616
+ # Drop the queued snapshot now that the dispatcher has it in
617
+ # flight. ``pending_dispatch()`` would otherwise keep returning
618
+ # the same generation on every ``drive_native_failsafe`` tick
619
+ # until the PUT result arrives, defeating the coalescing /
620
+ # rate-limit logic and inviting duplicate or out-of-order PUT
621
+ # callbacks. A newer ``recompute_worst_sl`` (or the retry path
622
+ # in ``record_put_failure``) re-queues a fresh snapshot when
623
+ # one is needed.
624
+ self._pending.pop(parent_entry_dispatch_ref, None)
625
+
626
+ # noinspection PyUnusedLocal
627
+ def record_put_success(
628
+ self,
629
+ parent_entry_dispatch_ref: str,
630
+ *,
631
+ generation: int,
632
+ now_ms: float,
633
+ ) -> None:
634
+ """Engine reports a successful PUT round-trip.
635
+
636
+ Only the structured PUT is recorded here. Snapshot confirmation
637
+ (``actual_level == desired_level``) lands via
638
+ :meth:`on_native_bracket_observed` from the next reconcile.
639
+ """
640
+ state = self._states.get(parent_entry_dispatch_ref)
641
+ if state is None:
642
+ return
643
+ # Ignore late successes from a generation that has already been
644
+ # superseded by a newer recompute, mirroring the failure path
645
+ # (:meth:`record_put_failure`). Clearing ``pending_put`` /
646
+ # ``pending_retry`` / failure metadata on a stale callback would
647
+ # erase the in-flight / retry state of the current snapshot —
648
+ # leaving the latest desired level un-dispatched (the queued
649
+ # snapshot is left alone here, but the next reconcile would
650
+ # treat the matched-but-superseded actual as an external edit
651
+ # in :meth:`on_native_bracket_observed`).
652
+ if generation < state.generation:
653
+ return
654
+ state.pending_put = False
655
+ state.pending_retry = False
656
+ state.last_failure_reason = None
657
+ state.last_failure_ts_ms = None
658
+ # The broker acknowledged a PUT for the current outstanding batch, so the
659
+ # confirmation-timeout window legitimately applies from now on: there is a
660
+ # real acked-but-not-yet-reconciled broker stop that a snapshot must
661
+ # confirm within ``stale_window_ms`` (see :meth:`_tick_confirmation_timeout`).
662
+ state.batch_put_acked = True
663
+ # Only drop the queued snapshot when the generation matches —
664
+ # a newer desired might have queued behind it while this PUT was
665
+ # in flight; that one must still be dispatched.
666
+ queued = self._pending.get(parent_entry_dispatch_ref)
667
+ if queued is not None and queued.generation == generation:
668
+ self._pending.pop(parent_entry_dispatch_ref, None)
669
+
670
+ def record_put_failure(
671
+ self,
672
+ parent_entry_dispatch_ref: str,
673
+ *,
674
+ generation: int,
675
+ reason: str,
676
+ now_ms: float,
677
+ ) -> None:
678
+ """Engine reports a failed PUT (rate limit, network, broker reject).
679
+
680
+ Three immediate retries are budgeted (§2.6.7); after exhaustion the
681
+ state moves to ``degrading``. If a confirmation does not land within
682
+ the stale window, ``degrading`` flips to ``degraded`` via
683
+ :meth:`tick_stale_window`.
684
+ """
685
+ state = self._states.get(parent_entry_dispatch_ref)
686
+ if state is None:
687
+ return
688
+ # Ignore late failures from a generation that has already been
689
+ # superseded by a newer recompute. ``state.generation`` only ever
690
+ # grows (see :meth:`recompute_worst_sl` / :meth:`flush_coalesced_trails`),
691
+ # so ``generation < state.generation`` means a fresher desired
692
+ # snapshot is already queued and possibly in flight. Decrementing
693
+ # the retry budget or flipping to ``degrading`` on a stale failure
694
+ # would penalise the latest snapshot for an outcome that does not
695
+ # describe it; the stale-generation handling already protects the
696
+ # success path at :meth:`record_put_success` and the failure path
697
+ # needs the same guard.
698
+ if generation < state.generation:
699
+ return
700
+ state.pending_put = False
701
+ state.last_failure_reason = reason
702
+ state.last_failure_ts_ms = now_ms
703
+ # Drop only the queued snapshot for this exact generation; newer
704
+ # ones queued meanwhile must survive.
705
+ queued = self._pending.get(parent_entry_dispatch_ref)
706
+ if queued is not None and queued.generation == generation:
707
+ self._pending.pop(parent_entry_dispatch_ref, None)
708
+
709
+ state.immediate_attempts_remaining = max(
710
+ 0, state.immediate_attempts_remaining - 1,
711
+ )
712
+ if state.immediate_attempts_remaining > 0:
713
+ # Same-tick retry: re-queue the current desired so the engine
714
+ # picks it up again on the next pending_dispatch() drain —
715
+ # *unless* the stale window already promoted the state to
716
+ # DEGRADED while this PUT was in flight (the DEGRADING-then-
717
+ # DEGRADED transition is driven by :meth:`tick_stale_window`,
718
+ # not by retry budget exhaustion, so a state with budget
719
+ # remaining can still flip when a fresh ``recompute_worst_sl``
720
+ # restocked ``immediate_attempts_remaining`` mid-window).
721
+ # Once DEGRADED, §2.6.7 forbids further engine-driven PUTs
722
+ # until ``set_risk`` / ``reset_to_engine``; re-queueing would
723
+ # have ``drive_native_failsafe`` dispatch another snapshot on
724
+ # the next tick and potentially overwrite a manual broker-side
725
+ # edit. Mirror the post-exhaustion DEGRADED guard below.
726
+ if state.health is FailsafeHealth.DEGRADED:
727
+ return
728
+ state.pending_retry = True
729
+ self._pending[parent_entry_dispatch_ref] = self._build_snapshot(state)
730
+ return
731
+
732
+ # Budget exhausted — start the degrading window.
733
+ if state.health is FailsafeHealth.HEALTHY:
734
+ # Anchor the stale-window timer on the first failure that drove
735
+ # the state to DEGRADING. ``tick_stale_window`` reads
736
+ # ``degrading_since_ts_ms``; ``last_failure_ts_ms`` keeps
737
+ # refreshing on every retry failure (the audit journal needs
738
+ # the latest), so using it as the anchor would let an API that
739
+ # keeps failing every poll slide the window forever.
740
+ state.degrading_since_ts_ms = now_ms
741
+ self._transition(state, FailsafeHealth.DEGRADING, reason)
742
+ if state.health is FailsafeHealth.DEGRADED:
743
+ # A retry PUT that was already in flight when
744
+ # :meth:`tick_stale_window` promoted the state to DEGRADED
745
+ # can land here with the current generation. Once DEGRADED,
746
+ # §2.6.7 forbids further engine-driven PUTs until the user
747
+ # explicitly calls ``set_risk`` / ``reset_to_engine``;
748
+ # re-queueing a snapshot now would have ``drive_native_failsafe``
749
+ # dispatch it on the next tick and potentially overwrite any
750
+ # manual broker-side edit the operator made after the
751
+ # failsafe was retired. Leave ``pending_retry`` cleared and
752
+ # the pending queue empty — :meth:`tick_stale_window`
753
+ # already wiped both at the DEGRADED transition.
754
+ return
755
+ state.pending_retry = True
756
+ # Keep the desired queued so the next poll tick can retry; the
757
+ # engine's poll loop is what drives the event-driven retry cadence
758
+ # (no sleep — `feedback_no_sleep`).
759
+ self._pending[parent_entry_dispatch_ref] = self._build_snapshot(state)
760
+
761
+ def tick_stale_window(self, *, now_ms: float) -> None:
762
+ """Poll-tick driven ``→ degraded`` escalations (§2.6.7).
763
+
764
+ Called periodically (once per sync, or once per reconcile poll).
765
+ Drives two independent escalations to ``degraded``:
766
+
767
+ - **DEGRADING → DEGRADED**: PUT retries are exhausted and no snapshot
768
+ has confirmed ``actual == desired`` within ``stale_window_ms``.
769
+ - **HEALTHY → DEGRADED (confirmation-timeout)**: the broker acked one or
770
+ more PUTs but a reconcile snapshot never reflected the latest desired
771
+ triple back within the window. Without this, an acked-but-never-
772
+ confirmed PUT keeps the state HEALTHY / engine-owned forever — a
773
+ silently dropped broker stop would violate the "bounded loss when
774
+ failsafe is verified present" guarantee. This escalation NEVER flips
775
+ ownership to UNKNOWN (nobody edited the stop) and NEVER blindly
776
+ re-dispatches (a broken feed proves nothing, and a re-PUT could clobber
777
+ a manual edit); it only engages the symbol-level gates until the
778
+ protection is verified present again (or a confirming snapshot
779
+ auto-recovers it via :meth:`on_native_bracket_observed`).
780
+ """
781
+ for state in list(self._states.values()):
782
+ if state.health is FailsafeHealth.DEGRADING:
783
+ self._tick_degrading_stale_window(state, now_ms=now_ms)
784
+ elif state.health is FailsafeHealth.HEALTHY and state.outstanding:
785
+ self._tick_confirmation_timeout(state, now_ms=now_ms)
786
+
787
+ # --- Snapshot reconcile --------------------------------------------
788
+
789
+ def on_native_bracket_observed(
790
+ self,
791
+ parent_entry_dispatch_ref: str,
792
+ *,
793
+ stop_level: float | None,
794
+ profit_level: float | None,
795
+ trailing_stop: float | None,
796
+ now_ms: float,
797
+ ) -> None:
798
+ """Reconcile callback for an observed broker-side bracket snapshot.
799
+
800
+ Drives three transitions:
801
+
802
+ - ``degrading → healthy`` when ``actual_level == desired_level``
803
+ and no PUT is in flight (§2.6.7 automatic recovery).
804
+ - ``engine-failsafe → unknown`` when ``actual_level != desired_level``
805
+ with no PUT in flight (external manual edit).
806
+ - ``healthy`` confirmation timestamp refresh on every match.
807
+ """
808
+ state = self._states.get(parent_entry_dispatch_ref)
809
+ if state is None:
810
+ return
811
+ # Snap observed broker levels onto the tick grid the desired snapshot
812
+ # already lives on, so confirmation / external-edit detection compares
813
+ # like-for-like. The broker reports its stored, tick-aligned levels,
814
+ # but float round-tripping (JSON, unit conversion) can still perturb
815
+ # them by a sub-tick — without this an exact match would never land.
816
+ stop_level = self._round_to_tick(stop_level, state)
817
+ profit_level = self._round_to_tick(profit_level, state)
818
+ trailing_stop = self._round_to_tick(trailing_stop, state)
819
+ state.actual_level = stop_level
820
+ state.actual_profit_level = profit_level
821
+ state.actual_trailing_stop = trailing_stop
822
+
823
+ # The full-replacement PUT carries SL, TP and trailing-stop
824
+ # together (see :meth:`_build_snapshot`), so confirmation /
825
+ # ownership must compare all three fields against the desired
826
+ # snapshot. Comparing the SL alone would treat an external edit
827
+ # to the TP or trailing distance (with SL untouched) as a match;
828
+ # ownership would stay with the engine and the next PUT would
829
+ # resend stale ``desired_profit_level`` / ``desired_trailing_stop``,
830
+ # overwriting that external edit.
831
+ match = (
832
+ self._levels_equal(stop_level, state.desired_level)
833
+ and self._levels_equal(profit_level, state.desired_profit_level)
834
+ and self._levels_equal(trailing_stop, state.desired_trailing_stop)
835
+ )
836
+ if state.pending_put:
837
+ # Confirmation must wait for the in-flight PUT result.
838
+ return
839
+
840
+ if match:
841
+ state.last_confirm_ts_ms = now_ms
842
+ # The broker now carries the LATEST desired triple — every
843
+ # outstanding entry (baseline + all dispatched levels) is consumed
844
+ # and must not exempt a later mismatch. Clearing the list also
845
+ # resets the confirmation-timeout anchor.
846
+ self._clear_outstanding(state)
847
+ if state.health is FailsafeHealth.DEGRADING:
848
+ # Clear the stale-window anchor: the next HEALTHY →
849
+ # DEGRADING cycle must re-anchor on the next first-failure.
850
+ state.degrading_since_ts_ms = None
851
+ self._transition(state, FailsafeHealth.HEALTHY, 'snapshot confirm')
852
+ elif (state.health is FailsafeHealth.DEGRADED
853
+ and state.degraded_reason == 'confirmation-timeout'
854
+ and state.owner is FailsafeOwner.ENGINE_FAILSAFE):
855
+ # A confirmation-timeout DEGRADED auto-recovers once the broker
856
+ # finally confirms the latest desired triple: the protection is
857
+ # verified present again, so no manual reset is required. A
858
+ # PUT-failure DEGRADED (``degraded_reason is None``) does NOT
859
+ # auto-recover — §2.6.7 keeps requiring an explicit user reset.
860
+ #
861
+ # The ownership guard is load-bearing: a confirmation-timeout
862
+ # DEGRADED whose broker stop was then manually edited takes the
863
+ # external-edit path below, which flips ``owner`` to UNKNOWN but
864
+ # leaves ``degraded_reason`` set. Without this guard a later
865
+ # observation that happens to equal the (stale) desired triple
866
+ # would recover the state to HEALTHY while ownership stays
867
+ # UNKNOWN — ``block_new_entry`` would stop blocking even though
868
+ # ``recompute_worst_sl`` is a no-op for non-engine ownership and
869
+ # the broker carries an operator's manual stop. UNKNOWN recovery
870
+ # requires an explicit ``reset_to_engine`` (§2.6.7).
871
+ state.degraded_reason = None
872
+ self._transition(state, FailsafeHealth.HEALTHY, 'confirmation recovered')
873
+ # A queued retry whose desired level matches what the broker
874
+ # is already carrying is a confirmed PUT we just couldn't
875
+ # observe directly (the failure report was wrong, or the
876
+ # success report was lost). Clear the retry flag and drop
877
+ # the queued snapshot so the next ``drive_native_failsafe``
878
+ # does not re-dispatch an already-landed PUT, and so the
879
+ # ``state.pending_retry`` guard below does not swallow a
880
+ # subsequent genuine external-edit mismatch.
881
+ if state.pending_retry:
882
+ state.pending_retry = False
883
+ queued = self._pending.get(parent_entry_dispatch_ref)
884
+ if queued is not None and self._levels_equal(
885
+ queued.stop_level, stop_level,
886
+ ):
887
+ self._pending.pop(parent_entry_dispatch_ref, None)
888
+ return
889
+
890
+ # Mismatch with no PUT in flight → either retry-pending (covered
891
+ # below), engine-throttled trail (covered next), or external edit
892
+ # (owner flip).
893
+ if state.pending_retry:
894
+ return
895
+ # A lifecycle / trail recompute may have already moved ``desired_level``
896
+ # and pushed one or more fresh PUTs whose results the broker has not
897
+ # reflected back yet. While those are unconfirmed the broker still
898
+ # legitimately carries one of the dispatched levels — or the pre-PUT
899
+ # baseline — so an observation diverging from the new ``desired_level``
900
+ # but equal to ANY outstanding entry is stale, not an external edit.
901
+ # Flipping ownership to UNKNOWN here would block future brackets until a
902
+ # manual reset. A single ``X → 90 → 85`` burst within one reconcile
903
+ # round-trip acks all three before any is observed back; a lagging poll
904
+ # of the intermediate 90 equals neither the latest desired (85) nor the
905
+ # baseline (X), so only the full outstanding list — not one scalar slot
906
+ # — can exempt it.
907
+ #
908
+ # The match must be on the FULL triple: the recompute only moves the SL,
909
+ # but a coexisting TP / trailing edit with the SL untouched is still a
910
+ # genuine external edit. On a match the broker has provably reached that
911
+ # dispatched level, so every entry older than the matched one can no
912
+ # longer be carried — prune them and keep the matched entry plus any
913
+ # newer ones (the latest desired may still be in flight behind it).
914
+ matched_idx = self._match_outstanding(
915
+ state, stop_level, profit_level, trailing_stop,
916
+ )
917
+ if matched_idx is not None:
918
+ del state.outstanding[:matched_idx]
919
+ return
920
+ # §2.6.5 trail coalesce / step-threshold: when `recompute_worst_sl`
921
+ # was called with ``trigger_kind='trail'`` and the dispatch was
922
+ # suppressed by the coalesce window or the step threshold, the
923
+ # engine deliberately updated ``state.desired_level`` without
924
+ # queueing a PUT. The broker therefore still carries
925
+ # ``last_trail_dispatched_level``. Treating that legitimate delay
926
+ # as an external edit would flip ownership to UNKNOWN and then
927
+ # ``flush_coalesced_trails`` would skip the parent forever (the
928
+ # owner filter at line 327 rejects non-ENGINE_FAILSAFE states).
929
+ # Only flag external edits when the actual diverges from BOTH
930
+ # the desired level and the last level we dispatched.
931
+ #
932
+ # ``pending_trail_change_ts_ms`` is the only signal that a trail
933
+ # update was just throttled — :meth:`recompute_worst_sl` sets it
934
+ # on the throttle branch and clears it on every immediate dispatch
935
+ # (lifecycle or trail that passed the gate). Without this gate
936
+ # the exemption would silently absorb genuine mismatches whenever
937
+ # a lifecycle recompute has moved ``desired_level`` away from
938
+ # ``last_trail_dispatched_level``: the broker would be carrying
939
+ # the OLD trail level while the engine thinks the level matches
940
+ # the throttled trail and the manager would stay HEALTHY /
941
+ # engine-owned despite the missing desired stop.
942
+ #
943
+ # TP / trailing-stop coalesce is *not* part of §2.6.5 — only the
944
+ # SL field is throttled. If the actual TP or trailing diverges
945
+ # from the desired snapshot, this is a genuine external edit
946
+ # regardless of the SL-side coalesce match, so the trail-coalesce
947
+ # exemption must also require those fields to agree.
948
+ if (state.pending_trail_change_ts_ms is not None
949
+ and state.last_trail_dispatched_level is not None
950
+ and self._levels_equal(stop_level, state.last_trail_dispatched_level)
951
+ and self._levels_equal(profit_level, state.desired_profit_level)
952
+ and self._levels_equal(trailing_stop, state.desired_trailing_stop)):
953
+ return
954
+ if state.owner is FailsafeOwner.ENGINE_FAILSAFE:
955
+ state.owner = FailsafeOwner.UNKNOWN
956
+ # Ownership left the engine because the broker carries an operator's
957
+ # manual edit — the outstanding baseline / dispatched levels are no
958
+ # longer meaningful. Clear the list (and reset the confirmation-
959
+ # timeout anchor) so a later ``reset_to_engine`` re-queue followed by
960
+ # an observation that happens to equal a now-stale outstanding level
961
+ # cannot be wrongly exempted.
962
+ self._clear_outstanding(state)
963
+ # Drop any snapshot a same-sync recompute queued but did not yet
964
+ # dispatch. The owner just flipped to UNKNOWN because the broker
965
+ # carries an operator's manual edit; the engine no longer owns the
966
+ # bracket, so it must not push the queued PUT. Leaving it in
967
+ # ``_pending`` would let the very next ``pending_dispatch()`` in
968
+ # this same ``drive_native_failsafe`` call (which does not filter
969
+ # by owner) realise the snapshot and overwrite the manual edit
970
+ # this guard exists to preserve.
971
+ self._pending.pop(parent_entry_dispatch_ref, None)
972
+ self._emit(BrokerNativeFailsafeExternalEditEvent(
973
+ parent_entry_dispatch_ref=parent_entry_dispatch_ref,
974
+ symbol=state.symbol,
975
+ desired_level=state.desired_level,
976
+ actual_level=stop_level,
977
+ ))
978
+
979
+ # noinspection PyUnusedLocal
980
+ def on_deal_id_disappeared(
981
+ self, parent_entry_dispatch_ref: str, *, now_ms: float,
982
+ ) -> None:
983
+ """The reconcile snapshot no longer lists this parent. The state
984
+ retires immediately (§2.6.7 lifecycle cleanup) so symbol-level
985
+ blocks unwind automatically.
986
+ """
987
+ state = self._states.get(parent_entry_dispatch_ref)
988
+ if state is None:
989
+ return
990
+ if state.health is FailsafeHealth.RETIRED:
991
+ return
992
+ self._transition(state, FailsafeHealth.RETIRED, 'dealId disappeared')
993
+ self._pending.pop(parent_entry_dispatch_ref, None)
994
+
995
+ # --- Ownership transitions -----------------------------------------
996
+
997
+ def claim_user_native(self, parent_entry_dispatch_ref: str) -> None:
998
+ """Mark a parent's bracket as user-managed (native full-row path
999
+ outside the SOFTWARE partial-qty-bracket capability). §2.6 does
1000
+ not run for these parents."""
1001
+ state = self._states.get(parent_entry_dispatch_ref)
1002
+ if state is None:
1003
+ return
1004
+ state.owner = FailsafeOwner.USER_NATIVE
1005
+
1006
+ def reset_to_engine(self, parent_entry_dispatch_ref: str, *, now_ms: float) -> None:
1007
+ """User explicitly reset ownership back to engine-failsafe after
1008
+ a manual intervention (§2.6.7 recovery from ``unknown`` /
1009
+ ``degraded``). Clears pending failure markers and re-queues the
1010
+ current desired snapshot.
1011
+
1012
+ The re-queue runs even when ``desired_level is None`` (clear
1013
+ snapshot). That case arises when the engine drove the broker-
1014
+ native stop *away* — the last SL leg was cancelled and the
1015
+ clear PUT exhausted retries, leaving the state DEGRADED with a
1016
+ stale stop still armed at the broker. A user reset must resend
1017
+ that clear so the stale broker stop is finally removed; gating
1018
+ on ``desired_level is not None`` would leave the parent
1019
+ protected by a stop the strategy no longer wants and risk an
1020
+ unexpected full-position close.
1021
+ """
1022
+ state = self._states.get(parent_entry_dispatch_ref)
1023
+ if state is None:
1024
+ return
1025
+ state.owner = FailsafeOwner.ENGINE_FAILSAFE
1026
+ state.immediate_attempts_remaining = self._put_max_attempts
1027
+ state.pending_retry = False
1028
+ state.last_failure_reason = None
1029
+ state.last_failure_ts_ms = None
1030
+ state.degrading_since_ts_ms = None
1031
+ # The user intervened, so any outstanding baseline / dispatched levels
1032
+ # are no longer a reliable picture of the broker. Drop them (and the
1033
+ # confirmation-timeout anchor / degraded reason) so the re-queued PUT
1034
+ # below starts a fresh confirmation cycle.
1035
+ self._clear_outstanding(state)
1036
+ state.degraded_reason = None
1037
+ state.generation += 1
1038
+ state.last_desired_change_ts_ms = now_ms
1039
+ snapshot = self._build_snapshot(state)
1040
+ self._pending[parent_entry_dispatch_ref] = snapshot
1041
+ # Seed the confirmation cycle for the re-queued PUT, mirroring
1042
+ # :meth:`recompute_worst_sl` / :meth:`flush_coalesced_trails`. Without
1043
+ # this the reset dispatch leaves ``outstanding`` empty, so
1044
+ # :meth:`tick_stale_window` never arms the confirmation-timeout path for
1045
+ # an acked-but-unconfirmed reset PUT (the state would stay HEALTHY with
1046
+ # an unverified broker stop forever), and a lagging pre-reset broker
1047
+ # observation arriving after the PUT succeeds matches no outstanding
1048
+ # entry and falsely flips ownership straight back to UNKNOWN. The user
1049
+ # intervened, so there is no trustworthy pre-reset broker baseline to
1050
+ # carry — the only level the broker may legitimately show next is the one
1051
+ # this reset dispatches, which ``_note_batch_start`` records (at the
1052
+ # post-bump generation) while it sets the batch-start anchor.
1053
+ self._note_batch_start(state, sl=state.desired_level, now_ms=now_ms)
1054
+ if state.health is not FailsafeHealth.HEALTHY:
1055
+ self._transition(state, FailsafeHealth.HEALTHY, 'user reset')
1056
+
1057
+ # --- Gates ---------------------------------------------------------
1058
+
1059
+ def is_new_partial_bracket_blocked(
1060
+ self,
1061
+ *,
1062
+ parent_entry_dispatch_ref: str,
1063
+ ) -> bool:
1064
+ """Side-effect-free probe of the §2.6.7 new-partial-bracket gate.
1065
+
1066
+ Returns the same boolean as :meth:`block_new_partial_bracket`
1067
+ without emitting :class:`PartialBracketBlockedDegradedFailsafeEvent`.
1068
+ Callers that need to *preflight* the gate (e.g. the sync engine
1069
+ deciding whether to evict the currently armed legs of an
1070
+ existing partial bracket before re-dispatching a modify) should
1071
+ use this probe so a "would-block" check does not pollute the
1072
+ audit log with spurious blocked-bracket events.
1073
+ """
1074
+ state = self._states.get(parent_entry_dispatch_ref)
1075
+ if state is None:
1076
+ return False
1077
+ return (
1078
+ state.health in (FailsafeHealth.DEGRADING, FailsafeHealth.DEGRADED)
1079
+ or state.owner is FailsafeOwner.UNKNOWN
1080
+ )
1081
+
1082
+ def block_new_partial_bracket(
1083
+ self,
1084
+ *,
1085
+ parent_entry_dispatch_ref: str,
1086
+ symbol: str,
1087
+ pine_id: str,
1088
+ from_entry: str,
1089
+ ) -> bool:
1090
+ """Return ``True`` when a new SOFTWARE partial-qty bracket
1091
+ dispatch for this parent must be rejected (§2.6.7).
1092
+ Emits the structured block event when blocking.
1093
+
1094
+ The gate blocks for two distinct conditions:
1095
+
1096
+ - ``health`` is ``degrading`` or ``degraded`` — PUT retries are
1097
+ exhausted or the stale window has expired.
1098
+ - ``owner`` is ``UNKNOWN`` (external manual edit observed via
1099
+ :meth:`on_native_bracket_observed`) — health may still read
1100
+ ``healthy``, but :meth:`recompute_worst_sl` is a no-op for
1101
+ non-``ENGINE_FAILSAFE`` ownership, so a freshly armed leg would
1102
+ add exposure without driving an updated worst-SL to the broker.
1103
+ Recovery requires :meth:`reset_to_engine` (user reset).
1104
+ """
1105
+ state = self._states.get(parent_entry_dispatch_ref)
1106
+ if state is None:
1107
+ return False
1108
+ if (
1109
+ state.health not in (FailsafeHealth.DEGRADING, FailsafeHealth.DEGRADED)
1110
+ and state.owner is not FailsafeOwner.UNKNOWN
1111
+ ):
1112
+ return False
1113
+ self._emit(PartialBracketBlockedDegradedFailsafeEvent(
1114
+ parent_entry_dispatch_ref=parent_entry_dispatch_ref,
1115
+ symbol=symbol,
1116
+ pine_id=pine_id,
1117
+ from_entry=from_entry,
1118
+ health=state.health.value,
1119
+ ))
1120
+ return True
1121
+
1122
+ def block_new_entry(
1123
+ self,
1124
+ *,
1125
+ symbol: str,
1126
+ pine_id: str,
1127
+ bar_ts_ms: int,
1128
+ ) -> bool:
1129
+ """Return ``True`` when ``strategy.entry`` on ``symbol`` must be
1130
+ dropped because at least one parent on this symbol holds a
1131
+ ``degrading`` / ``degraded`` failsafe (§2.6.7).
1132
+
1133
+ Drop-semantics: the engine does NOT queue the signal — see the
1134
+ :class:`EntrySkippedDueToDegradedFailsafeEvent` rationale.
1135
+ """
1136
+ worst: FailsafeHealth | None = None
1137
+ for state in self._states.values():
1138
+ if state.symbol != symbol:
1139
+ continue
1140
+ if state.health in (FailsafeHealth.DEGRADING, FailsafeHealth.DEGRADED):
1141
+ if worst is None or state.health is FailsafeHealth.DEGRADED:
1142
+ worst = state.health
1143
+ if worst is None:
1144
+ return False
1145
+ self._emit(EntryBlockedDegradedFailsafeEvent(
1146
+ symbol=symbol,
1147
+ pine_id=pine_id,
1148
+ health=worst.value,
1149
+ ))
1150
+ self._emit(EntrySkippedDueToDegradedFailsafeEvent(
1151
+ symbol=symbol,
1152
+ pine_id=pine_id,
1153
+ bar_ts_ms=bar_ts_ms,
1154
+ ))
1155
+ return True
1156
+
1157
+ # --- Internals -----------------------------------------------------
1158
+
1159
+ def _worst_sl(
1160
+ self, state: NativeStopState, active_sl_levels: Iterable[float],
1161
+ ) -> float | None:
1162
+ """Worst-SL across the active legs, snapped to the symbol's tick grid.
1163
+
1164
+ The *worst* stop is the loosest one still protecting the parent: the
1165
+ lowest for a long, the highest for a short. An empty set means no leg
1166
+ wants a stop, so the desired level is ``None`` (a clear). Records the
1167
+ leg count for the ``degrading → healthy`` recovery telemetry and snaps
1168
+ the result to the tick grid so a later broker observation (also snapped
1169
+ in :meth:`on_native_bracket_observed`) confirms by exact compare.
1170
+ """
1171
+ levels = list(active_sl_levels)
1172
+ state.last_active_sl_count = len(levels)
1173
+ if not levels:
1174
+ return None
1175
+ worst = min(levels) if state.parent_side == 'long' else max(levels)
1176
+ return self._round_to_tick(worst, state)
1177
+
1178
+ # noinspection PyMethodMayBeStatic
1179
+ def _build_snapshot(self, state: NativeStopState) -> NativeBracketSnapshot:
1180
+ return NativeBracketSnapshot(
1181
+ parent_entry_dispatch_ref=state.parent_entry_dispatch_ref,
1182
+ symbol=state.symbol,
1183
+ parent_side=state.parent_side,
1184
+ stop_level=state.desired_level,
1185
+ profit_level=state.desired_profit_level,
1186
+ trailing_stop=state.desired_trailing_stop,
1187
+ generation=state.generation,
1188
+ )
1189
+
1190
+ # --- Outstanding-levels bookkeeping (§2.6.7 confirmation tracking) --
1191
+
1192
+ # noinspection PyMethodMayBeStatic
1193
+ def _note_batch_start(
1194
+ self, state: NativeStopState, *, sl: float | None, now_ms: float,
1195
+ ) -> None:
1196
+ """Capture the broker baseline at the start of a fresh in-flight batch.
1197
+
1198
+ When ``outstanding`` is empty the broker is showing ``sl`` together with
1199
+ the unchanged coexisting TP / trailing; record that triple as the oldest
1200
+ outstanding entry (it stays exempt until a confirmation prunes it) and
1201
+ anchor the confirmation-timeout window at ``now_ms``. Called before the
1202
+ generation bump, so the baseline carries the generation the broker level
1203
+ currently corresponds to. A no-op once the list is non-empty, so a
1204
+ follow-up dispatch arriving while earlier PUTs are unconfirmed never
1205
+ clobbers the genuine baseline.
1206
+ """
1207
+ if state.outstanding:
1208
+ return
1209
+ state.outstanding_since_ts_ms = now_ms
1210
+ state.outstanding.append(OutstandingLevel(
1211
+ sl=sl,
1212
+ profit_level=state.desired_profit_level,
1213
+ trailing_stop=state.desired_trailing_stop,
1214
+ generation=state.generation,
1215
+ dispatch_ts_ms=now_ms,
1216
+ ))
1217
+
1218
+ def _append_outstanding(
1219
+ self, state: NativeStopState, snapshot: NativeBracketSnapshot, *,
1220
+ now_ms: float,
1221
+ ) -> None:
1222
+ """Record a freshly dispatched level as an outstanding entry."""
1223
+ state.outstanding.append(OutstandingLevel(
1224
+ sl=snapshot.stop_level,
1225
+ profit_level=snapshot.profit_level,
1226
+ trailing_stop=snapshot.trailing_stop,
1227
+ generation=snapshot.generation,
1228
+ dispatch_ts_ms=now_ms,
1229
+ ))
1230
+ self._compact_outstanding(state)
1231
+
1232
+ # noinspection PyMethodMayBeStatic
1233
+ def _clear_outstanding(self, state: NativeStopState) -> None:
1234
+ """Drop all outstanding entries and reset the confirmation-timeout
1235
+ anchor — used on a latest-desired confirm, an external edit, and a
1236
+ user reset."""
1237
+ state.outstanding.clear()
1238
+ state.outstanding_since_ts_ms = None
1239
+ state.batch_put_acked = False
1240
+
1241
+ def _match_outstanding(
1242
+ self,
1243
+ state: NativeStopState,
1244
+ stop_level: float | None,
1245
+ profit_level: float | None,
1246
+ trailing_stop: float | None,
1247
+ ) -> int | None:
1248
+ """Index of the HIGHEST-generation outstanding entry whose full triple
1249
+ equals the observation, or ``None`` when none match.
1250
+
1251
+ Entries are appended in generation order, so the highest index is the
1252
+ highest generation; returning the latest match lets the caller prune
1253
+ every older entry the broker has provably moved past.
1254
+ """
1255
+ for i in range(len(state.outstanding) - 1, -1, -1):
1256
+ entry = state.outstanding[i]
1257
+ if (self._levels_equal(stop_level, entry.sl)
1258
+ and self._levels_equal(profit_level, entry.profit_level)
1259
+ and self._levels_equal(trailing_stop, entry.trailing_stop)):
1260
+ return i
1261
+ return None
1262
+
1263
+ # noinspection PyMethodMayBeStatic
1264
+ def _compact_outstanding(self, state: NativeStopState) -> None:
1265
+ """Cap the outstanding list length (memory backstop only).
1266
+
1267
+ Correctness comes from confirmation-driven pruning in
1268
+ :meth:`on_native_bracket_observed` and from :meth:`tick_stale_window`
1269
+ freezing growth once the confirmation-timeout window expires (``degraded``
1270
+ makes :meth:`recompute_worst_sl` a no-op). This cap is never reached in
1271
+ practice — it only bounds memory under a pathological never-confirming
1272
+ churn. The newest entries (including the current desired target) are
1273
+ kept; the oldest baseline is the first dropped, which is safe because by
1274
+ the time the cap is hit the state is long since DEGRADED.
1275
+ """
1276
+ excess = len(state.outstanding) - _OUTSTANDING_LEVELS_CAP
1277
+ if excess > 0:
1278
+ del state.outstanding[:excess]
1279
+
1280
+ def _tick_degrading_stale_window(
1281
+ self, state: NativeStopState, *, now_ms: float,
1282
+ ) -> None:
1283
+ """DEGRADING → DEGRADED once the PUT-failure stale window expires."""
1284
+ window = state.stale_window_ms or self._stale_window_ms
1285
+ # ``degrading_since_ts_ms`` is set when the state enters DEGRADING and
1286
+ # stays frozen across subsequent retry failures so the timer measures
1287
+ # "time since the failsafe started failing". The legacy
1288
+ # ``last_failure_ts_ms`` fall-back covers states that were already
1289
+ # DEGRADING before this field existed (persisted/restored state). Use
1290
+ # ``is not None`` rather than truthiness so a deterministic test/replay
1291
+ # clock starting at ``0.0`` still anchors at the first failure instead
1292
+ # of sliding forward on every retry.
1293
+ if state.degrading_since_ts_ms is not None:
1294
+ anchor = state.degrading_since_ts_ms
1295
+ elif state.last_failure_ts_ms is not None:
1296
+ anchor = state.last_failure_ts_ms
1297
+ elif state.last_desired_change_ts_ms is not None:
1298
+ anchor = state.last_desired_change_ts_ms
1299
+ else:
1300
+ return
1301
+ if (now_ms - anchor) < window:
1302
+ return
1303
+ self._transition(state, FailsafeHealth.DEGRADED, 'stale-window expired')
1304
+ # Once the stale window has expired, manual ``set_risk`` /
1305
+ # ``reset_to_engine`` is required before the engine writes the
1306
+ # broker-native stop again (§2.6.7). Leaving ``pending_retry`` and the
1307
+ # queued snapshot intact would let the very next ``drive_native_failsafe``
1308
+ # tick re-dispatch the same failed PUT — ``pending_dispatch()`` runs
1309
+ # immediately after ``tick_stale_window`` — and ``record_put_failure``
1310
+ # would then re-queue it on every failure. Drop both so DEGRADED really
1311
+ # blocks dispatch until the user resets.
1312
+ state.pending_retry = False
1313
+ self._pending.pop(state.parent_entry_dispatch_ref, None)
1314
+ self._emit(BrokerNativeFailsafeUnavailableEvent(
1315
+ parent_entry_dispatch_ref=state.parent_entry_dispatch_ref,
1316
+ symbol=state.symbol,
1317
+ reason=state.last_failure_reason or 'stale-window expired',
1318
+ ))
1319
+
1320
+ def _tick_confirmation_timeout(
1321
+ self, state: NativeStopState, *, now_ms: float,
1322
+ ) -> None:
1323
+ """HEALTHY → DEGRADED when dispatched levels go unconfirmed too long.
1324
+
1325
+ The anchor is the batch-start timestamp (``outstanding_since_ts_ms``),
1326
+ not the newest dispatch: churn that re-dispatches faster than the broker
1327
+ confirms must not slide the deadline forever, and a long-idle parent
1328
+ must not be punished the instant it dispatches again. Only a confirming
1329
+ snapshot of the latest desired triple clears the list and resets the
1330
+ anchor, so the window genuinely measures "how long the current batch has
1331
+ been unconfirmed".
1332
+ """
1333
+ if not state.batch_put_acked:
1334
+ # No PUT for this batch has been acknowledged by the broker yet
1335
+ # (state-only run, the queue not drained, a PUT still in flight, or a
1336
+ # PUT that failed and is awaiting a budgeted retry). There is no
1337
+ # acked-but-unconfirmed broker stop to time out — escalating now would
1338
+ # wrongly DEGRADED-block entries (and a failed PUT with retries left
1339
+ # is owned by the retry budget → DEGRADING path, not this one). Once
1340
+ # :meth:`record_put_success` acks a PUT the window applies.
1341
+ return
1342
+ anchor = state.outstanding_since_ts_ms
1343
+ if anchor is None:
1344
+ return
1345
+ window = state.stale_window_ms or self._stale_window_ms
1346
+ if (now_ms - anchor) < window:
1347
+ return
1348
+ # Confirmation never arrived. Escalate to DEGRADED so the symbol-level
1349
+ # gates engage — but DO NOT touch ownership (nobody edited the stop) and
1350
+ # DO NOT re-dispatch (a broken / again-lagging feed proves nothing, and a
1351
+ # blind re-PUT could clobber a manual edit). A later confirming snapshot
1352
+ # auto-recovers this state in :meth:`on_native_bracket_observed`; the
1353
+ # ``degraded_reason`` tag scopes that recovery so a PUT-failure DEGRADED
1354
+ # still requires an explicit user reset.
1355
+ state.degraded_reason = 'confirmation-timeout'
1356
+ self._transition(state, FailsafeHealth.DEGRADED, 'confirmation-timeout')
1357
+ # Defensively drop any queued snapshot so ``pending_dispatch()`` (which
1358
+ # runs straight after this tick) cannot blind-re-dispatch. There is
1359
+ # normally nothing queued here — the PUTs were acked — but a stray entry
1360
+ # must not slip a PUT past the DEGRADED gate.
1361
+ state.pending_retry = False
1362
+ self._pending.pop(state.parent_entry_dispatch_ref, None)
1363
+ self._emit(BrokerNativeFailsafeUnavailableEvent(
1364
+ parent_entry_dispatch_ref=state.parent_entry_dispatch_ref,
1365
+ symbol=state.symbol,
1366
+ reason='confirmation-timeout',
1367
+ ))
1368
+
1369
+ # noinspection PyMethodMayBeStatic
1370
+ def _round_to_tick(
1371
+ self, level: float | None, state: NativeStopState,
1372
+ ) -> float | None:
1373
+ """Round a price level to the symbol's mintick grid.
1374
+
1375
+ Mirrors :func:`pynecore.lib.math.round_to_mintick` exactly — ties round
1376
+ up, and the grid is reconstructed as ``int(level / mintick + 0.5) *
1377
+ minmove / pricescale`` so awkward ticks (e.g. ``0.025`` →
1378
+ ``minmove=2.5``, ``pricescale=100``) do not accumulate float drift. The
1379
+ manager is symbol-agnostic — one instance serves many parents across
1380
+ symbols — so it cannot read the process-global ``syminfo``; the grid
1381
+ travels per state via :meth:`register_parent`.
1382
+
1383
+ :param level: Price level to snap, or ``None`` (passed through).
1384
+ :param state: Per-parent state carrying the symbol's tick grid.
1385
+ :returns: ``level`` snapped to the grid, or unchanged when the grid is
1386
+ unknown (any factor still at the ``0`` sentinel) — which keeps
1387
+ default-constructed / test states on their original exact levels.
1388
+ """
1389
+ if level is None:
1390
+ return None
1391
+ if state.mintick <= 0.0 or state.minmove <= 0.0 or state.pricescale <= 0:
1392
+ return level
1393
+ return int(level / state.mintick + 0.5) * state.minmove / state.pricescale
1394
+
1395
+ def _levels_equal(self, a: float | None, b: float | None) -> bool:
1396
+ if a is None and b is None:
1397
+ return True
1398
+ if a is None or b is None:
1399
+ return False
1400
+ return abs(a - b) <= self._eps
1401
+
1402
+ def _trail_should_dispatch(
1403
+ self,
1404
+ state: NativeStopState,
1405
+ new_desired: float | None,
1406
+ now_ms: float,
1407
+ ) -> bool:
1408
+ last_ts = state.last_trail_dispatch_ts_ms
1409
+ if last_ts is not None and (now_ms - last_ts) < self._trail_coalesce_window_ms:
1410
+ return False
1411
+ if state.last_trail_dispatched_level is None or new_desired is None:
1412
+ return True
1413
+ step = abs(new_desired - state.last_trail_dispatched_level)
1414
+ threshold = state.mintick * self._trail_step_threshold_ticks
1415
+ # See :meth:`flush_coalesced_trails`: tolerate sub-ULP float error so a
1416
+ # genuine grid-snapped 1-tick move is not swallowed by the threshold.
1417
+ return step >= threshold - self._eps
1418
+
1419
+ def _transition(
1420
+ self, state: NativeStopState, new_health: FailsafeHealth, reason: str,
1421
+ ) -> None:
1422
+ if state.health is new_health:
1423
+ return
1424
+ old = state.health
1425
+ state.health = new_health
1426
+ self._emit(NativeFailsafeStateTransitionEvent(
1427
+ parent_entry_dispatch_ref=state.parent_entry_dispatch_ref,
1428
+ symbol=state.symbol,
1429
+ from_state=old.value,
1430
+ to_state=new_health.value,
1431
+ reason=reason,
1432
+ ))
1433
+
1434
+ def _emit(self, event: BrokerEvent) -> None:
1435
+ if self._event_sink is not None:
1436
+ self._event_sink(event)