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,2161 @@
1
+ """
2
+ Typed helpers over :class:`~pynecore.core.broker.storage.RunContext`.
3
+
4
+ The :class:`BrokerStore` exposes :meth:`upsert_order` with a free-form
5
+ ``extras`` dict and individual setters per column. Each broker plugin
6
+ currently re-implements the same micro-patterns on top of that surface:
7
+ seed a row with ``{'kind': ..., 'order_type': ...}`` extras, record the
8
+ server reference, lift the state machine to ``'confirmed'`` with the
9
+ fill price persisted under ``extras['confirm_level']``. The keys are
10
+ implicit, the ordering of writes is implicit, and a typo causes silent
11
+ divergence between Pine intent and broker truth.
12
+
13
+ These helpers turn the most common ``execute_entry`` lifecycle steps
14
+ into typed function calls. They are intentionally a *thin* layer:
15
+ nothing in this module talks to the exchange, retries, sleeps, or makes
16
+ state-machine decisions beyond what the orchestrating
17
+ :class:`~pynecore.core.broker.journal.DispatchJournal` instructs. The
18
+ module is the single place where the canonical ``extras`` schema for
19
+ non-bracket entries lives.
20
+
21
+ Scope is intentionally limited to the M1 proof-of-shape — entry orders
22
+ only, no bracket legs, no trail state, no natural-close flags. Those
23
+ land in later milestones once a second broker plugin confirms the
24
+ shape.
25
+
26
+ See ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``
27
+ section §4 for the rationale.
28
+ """
29
+ from collections.abc import Iterator, Mapping
30
+ from typing import Any, TYPE_CHECKING
31
+
32
+ if TYPE_CHECKING:
33
+ from pynecore.core.broker.storage import OrderRow, RunContext
34
+
35
+ __all__ = [
36
+ 'ENTRY_KIND_POSITION',
37
+ 'ENTRY_KIND_WORKING',
38
+ 'KIND_FULL_CLOSE',
39
+ 'KIND_PARTIAL_CLOSE',
40
+ 'KIND_CANCEL',
41
+ 'KIND_MODIFY_ENTRY',
42
+ 'KIND_MODIFY_EXIT',
43
+ 'CLOSE_KINDS',
44
+ 'STATE_SUBMITTED',
45
+ 'STATE_SERVER_REF_SEEN',
46
+ 'STATE_CONFIRMED',
47
+ 'STATE_REJECTED',
48
+ 'STATE_DISPOSITION_UNKNOWN',
49
+ 'STATE_CLOSING',
50
+ 'STATE_CANCEL_PENDING',
51
+ 'STATE_PARTIAL_BRACKET_LEG',
52
+ 'PENDING_DISPATCH_STATES',
53
+ 'LEG_KIND_TP_PARTIAL',
54
+ 'LEG_KIND_SL_PARTIAL',
55
+ 'LEG_KIND_TRAIL_PARTIAL',
56
+ 'ENGINE_TRIGGER_LEG_KINDS',
57
+ 'LEG_STATE_ARMED',
58
+ 'LEG_STATE_PENDING_ENTRY',
59
+ 'LEG_STATE_TRIGGERING',
60
+ 'LEG_STATE_TRIGGERED',
61
+ 'LEG_STATE_TRIGGERED_FAILED',
62
+ 'LEG_STATE_TRIGGERED_UNKNOWN',
63
+ 'LEG_STATE_ABORTED_PARENT_GONE',
64
+ 'LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED',
65
+ 'LEG_STATE_CASCADED_CANCEL',
66
+ 'LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE',
67
+ 'LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL',
68
+ 'LEG_STATE_CANCEL_TENTATIVE',
69
+ 'LEG_STATE_TERMINAL',
70
+ 'LEG_STATE_ACTIVE',
71
+ 'LEG_STATE_LIVE',
72
+ 'EXTRAS_KEY_LEG_KIND',
73
+ 'EXTRAS_KEY_LEG_STATE',
74
+ 'EXTRAS_KEY_TRIGGER_LEVEL',
75
+ 'EXTRAS_KEY_TRIGGER_OFFSET',
76
+ 'EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL',
77
+ 'EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET',
78
+ 'EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF',
79
+ 'EXTRAS_KEY_INTENT_PARTIAL_QTY',
80
+ 'EXTRAS_KEY_PARENT_PINE_ENTRY_ID',
81
+ 'EXTRAS_KEY_OCA_GROUP',
82
+ 'EXTRAS_KEY_OCA_TYPE',
83
+ 'EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS',
84
+ 'create_entry_order_row',
85
+ 'record_server_ref',
86
+ 'mark_confirmed_with_fill',
87
+ 'mark_disposition_unknown',
88
+ 'mark_rejected',
89
+ 'find_pending_dispatch',
90
+ 'create_close_target_row',
91
+ 'record_close_server_ref',
92
+ 'mark_closing',
93
+ 'mark_close_completed',
94
+ 'create_cancel_command_row',
95
+ 'mark_cancel_completed',
96
+ 'create_modify_entry_row',
97
+ 'create_modify_exit_row',
98
+ 'mark_modify_completed',
99
+ 'mark_reconcile_filled',
100
+ 'mark_reconcile_terminal_close',
101
+ 'create_engine_trigger_partial_leg_row',
102
+ 'update_engine_trigger_partial_leg_state',
103
+ 'iter_active_engine_trigger_partial_legs',
104
+ 'STATE_CLOSE_LEG',
105
+ 'CLOSE_LEG_STATE_PENDING',
106
+ 'CLOSE_LEG_STATE_DISPATCHED',
107
+ 'CLOSE_LEG_STATE_LIVE',
108
+ 'EXTRAS_KEY_CLOSE_LEG_STATE',
109
+ 'EXTRAS_KEY_CLOSE_LEG_ID',
110
+ 'EXTRAS_KEY_CLOSE_PARENT_COID',
111
+ 'EXTRAS_KEY_CLOSE_LEG_VOLUME',
112
+ 'create_close_leg_row',
113
+ 'update_close_leg_state',
114
+ 'iter_active_close_legs',
115
+ 'STATE_RESIDUAL_OPEN',
116
+ 'EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID',
117
+ 'EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG',
118
+ 'EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS',
119
+ 'EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ',
120
+ 'create_residual_open_row',
121
+ 'iter_active_residual_opens',
122
+ 'clear_residual_open_row',
123
+ 'STATE_BRACKET_OWN',
124
+ 'BRACKET_OWN_STATE_ACTIVE',
125
+ 'BRACKET_OWN_STATE_CLEARING',
126
+ 'BRACKET_OWN_STATE_RELEASED',
127
+ 'BRACKET_OWN_STATE_LIVE',
128
+ 'EXTRAS_KEY_BRACKET_OWN_STATE',
129
+ 'EXTRAS_KEY_BRACKET_OWN_LEG_ID',
130
+ 'EXTRAS_KEY_BRACKET_OWN_ATTACH_COID',
131
+ 'EXTRAS_KEY_BRACKET_OWN_CLEAR_COID',
132
+ 'EXTRAS_KEY_BRACKET_OWN_TP',
133
+ 'EXTRAS_KEY_BRACKET_OWN_SL',
134
+ 'EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE',
135
+ 'EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET',
136
+ 'EXTRAS_KEY_BRACKET_OWN_OCA_NAME',
137
+ 'EXTRAS_KEY_BRACKET_OWN_OCA_TYPE',
138
+ 'create_bracket_ownership_row',
139
+ 'update_bracket_ownership_state',
140
+ 'iter_active_bracket_ownerships',
141
+ 'ENTRY_STOP_STATE_ARMED',
142
+ 'ENTRY_STOP_STATE_CANCEL_PENDING',
143
+ 'ENTRY_STOP_STATE_MARKET_PENDING',
144
+ 'create_entry_stop_watch_row',
145
+ 'ENTRY_STOP_STATE_ABORTABLE',
146
+ 'ENTRY_STOP_STATE_ABORTED',
147
+ 'ENTRY_STOP_STATE_LIMIT_WON',
148
+ 'ENTRY_STOP_STATE_LIVE',
149
+ 'ENTRY_STOP_STATE_STOP_WON',
150
+ 'EXTRAS_KEY_ENTRY_STOP_LEVEL',
151
+ 'EXTRAS_KEY_ENTRY_STOP_LIMIT_COID',
152
+ 'EXTRAS_KEY_ENTRY_STOP_MARKET_COID',
153
+ 'EXTRAS_KEY_ENTRY_STOP_STATE',
154
+ 'iter_active_entry_stop_watches',
155
+ 'update_entry_stop_watch_state',
156
+ ]
157
+
158
+
159
+ # === Canonical extras values ===============================================
160
+
161
+ # ``extras['kind']`` for an entry that opens a position immediately
162
+ # (MARKET order). The plugin's reconcile / activity paths key off this
163
+ # to know the row maps to ``/positions`` rather than ``/workingorders``.
164
+ ENTRY_KIND_POSITION = 'position'
165
+
166
+ # ``extras['kind']`` for an entry placed as a working order (LIMIT or
167
+ # STOP). The reconcile / activity paths key off this to know the row
168
+ # maps to ``/workingorders`` rather than ``/positions``.
169
+ ENTRY_KIND_WORKING = 'working'
170
+
171
+ # ``extras['kind']`` for a close that targets every unit of one or more
172
+ # live positions. The plugin issues a DELETE per target ``dealId``; no
173
+ # server reference is allocated (DELETE is fire-and-forget). Recovery
174
+ # reconciles the persisted ``extras['targets']`` against the live
175
+ # positions snapshot.
176
+ KIND_FULL_CLOSE = 'full_close'
177
+
178
+ # ``extras['kind']`` for a close that reduces an existing position by
179
+ # a strict subset of its units. Emulated via an opposite-direction
180
+ # POST to ``/positions`` (Capital.com has no native partial-close
181
+ # endpoint), so the row owns a server reference like an entry would.
182
+ # Recovery uses the ``deal_reference`` confirm GET plus a unit-count
183
+ # delta against the pre/post position snapshot.
184
+ KIND_PARTIAL_CLOSE = 'partial_close_emulated'
185
+
186
+ # Convenience set used by close-aware recovery branches that route by
187
+ # ``extras['kind']`` and need to handle both full and partial close
188
+ # rows identically.
189
+ CLOSE_KINDS: frozenset[str] = frozenset({KIND_FULL_CLOSE, KIND_PARTIAL_CLOSE})
190
+
191
+ # ``extras['kind']`` for a cancel command row. Carries the per-dispatch
192
+ # audit trail for a ``CancelIntent``; the row does not itself land on
193
+ # the exchange. The targets it sweeps are referenced via
194
+ # ``extras['target_coids']`` so a mid-loop crash can re-evaluate which
195
+ # targets still need cancelling on recovery.
196
+ KIND_CANCEL = 'cancel'
197
+
198
+ # ``extras['kind']`` for a working-order amend (PUT level). The row
199
+ # carries the audit trail for the amend dispatch and stores the new
200
+ # level under ``extras['new_level']`` so recovery can verify whether
201
+ # the broker landed the change.
202
+ KIND_MODIFY_ENTRY = 'modify_entry'
203
+
204
+ # ``extras['kind']`` for a position bracket amend (PUT TP / SL /
205
+ # trailing). The row is the entry-side audit trail; the synthetic
206
+ # bracket leg rows that mirror the resulting TP / SL state remain
207
+ # under the plugin's leg state machine for the duration of M4.
208
+ KIND_MODIFY_EXIT = 'modify_exit'
209
+
210
+
211
+ # === Canonical states ======================================================
212
+
213
+ # Order row persisted, REST POST not yet attempted.
214
+ STATE_SUBMITTED = 'submitted'
215
+
216
+ # REST POST returned a server reference (``dealReference`` for
217
+ # Capital.com). Confirm / readback is the next step.
218
+ STATE_SERVER_REF_SEEN = 'server_ref_seen'
219
+
220
+ # Confirm succeeded; ``exchange_order_id`` populated. For MARKET orders
221
+ # this also implies a fill, with the fill price under
222
+ # ``extras['confirm_level']`` and ``filled_qty`` non-zero.
223
+ STATE_CONFIRMED = 'confirmed'
224
+
225
+ # Exchange rejected the dispatch synchronously (confirm REJECTED, or a
226
+ # 4xx response with a known reject reason). Terminal.
227
+ STATE_REJECTED = 'rejected'
228
+
229
+ # Submission outcome unknown — typically a network timeout between the
230
+ # POST and the response, or a successful POST without a server
231
+ # reference. The recovery path uses these rows on startup to replay or
232
+ # reconcile against the exchange.
233
+ STATE_DISPOSITION_UNKNOWN = 'disposition_unknown'
234
+
235
+ # A full-close DELETE landed at the exchange but the final fill /
236
+ # settlement has not yet been observed in the activity stream. The
237
+ # recovery path treats this state like a pending dispatch: the targets
238
+ # are re-queried against the live positions snapshot to confirm they
239
+ # have actually vanished.
240
+ STATE_CLOSING = 'closing'
241
+
242
+ # A cancel command row is mid-flight: the per-target loop has started
243
+ # but not finished. Recovery re-evaluates the targets against the
244
+ # snapshot and retries the DELETEs that did not land.
245
+ STATE_CANCEL_PENDING = 'cancel_pending'
246
+
247
+ # ``orders.state`` marker for a synthetic engine-trigger partial bracket
248
+ # leg row. The order lifecycle column does NOT track the engine-trigger
249
+ # state machine — the actual leg phase (``armed`` → ``triggering`` →
250
+ # ``triggered`` / ``cascaded_cancel`` / ...) lives under
251
+ # :data:`EXTRAS_KEY_LEG_STATE`. This marker exists so the legacy
252
+ # recovery / reconcile paths can short-circuit on these rows (they own
253
+ # no exchange-side order; the engine state machine in
254
+ # :mod:`software_partial_bracket_engine` owns them).
255
+ STATE_PARTIAL_BRACKET_LEG = 'partial_bracket_leg'
256
+
257
+ # States the recovery path must examine after a restart. ``submitted``
258
+ # is here because a crash between the initial helper and the REST call
259
+ # leaves the row in this state — same recovery semantics as
260
+ # ``disposition_unknown``. The non-entry pending states (``closing``,
261
+ # ``cancel_pending``) are added to this set by the corresponding M4
262
+ # phases once the matching ``resume_pending_dispatch`` branches exist
263
+ # on the plugin side; until then the journal does not surface them to
264
+ # the recovery loop.
265
+ #
266
+ # :data:`STATE_PARTIAL_BRACKET_LEG` is deliberately NOT a member —
267
+ # engine-trigger leg rows are replayed by
268
+ # :meth:`SoftwarePartialBracketEngine.restart_replay`, not by the
269
+ # journal's resume hook.
270
+ PENDING_DISPATCH_STATES: frozenset[str] = frozenset({
271
+ STATE_SUBMITTED,
272
+ STATE_SERVER_REF_SEEN,
273
+ STATE_DISPOSITION_UNKNOWN,
274
+ })
275
+
276
+
277
+ # === Engine-trigger partial bracket leg constants ==========================
278
+
279
+ # ``extras[EXTRAS_KEY_LEG_KIND]`` for the take-profit leg of an
280
+ # engine-trigger partial bracket. The motor closes a strict subset of
281
+ # the parent position at this price level.
282
+ LEG_KIND_TP_PARTIAL = 'tp_partial'
283
+
284
+ # ``extras[EXTRAS_KEY_LEG_KIND]`` for the stop-loss leg of an
285
+ # engine-trigger partial bracket.
286
+ LEG_KIND_SL_PARTIAL = 'sl_partial'
287
+
288
+ # ``extras[EXTRAS_KEY_LEG_KIND]`` for the trailing-stop leg of an
289
+ # engine-trigger partial bracket. The ``trigger_level`` for a trail
290
+ # leg is recomputed by the engine on every favourable price move
291
+ # from ``trigger_offset``.
292
+ LEG_KIND_TRAIL_PARTIAL = 'trail_partial'
293
+
294
+ # Set of leg kinds owned by the engine-trigger state machine —
295
+ # reconciliation / replay paths key off membership here to identify
296
+ # rows whose disposition is NOT the journal's responsibility.
297
+ ENGINE_TRIGGER_LEG_KINDS: frozenset[str] = frozenset({
298
+ LEG_KIND_TP_PARTIAL,
299
+ LEG_KIND_SL_PARTIAL,
300
+ LEG_KIND_TRAIL_PARTIAL,
301
+ })
302
+
303
+ # ``extras[EXTRAS_KEY_LEG_STATE]`` values — the engine-trigger state
304
+ # machine's current phase. See the partial-qty bracket exit design
305
+ # dossier §3 for the full lifecycle diagram. ``armed`` is the steady
306
+ # state where the motor watches WS price ticks; ``triggering`` is the
307
+ # transient between trigger detection and the close dispatch landing;
308
+ # ``triggered`` / ``triggered_failed`` / ``triggered_unknown`` are the
309
+ # three close-dispatch outcomes. The ``aborted_*`` and ``cascaded_*``
310
+ # branches are the terminal states for legs that the engine does not
311
+ # fire (parent gone, OCA cancellation, broker-native SL hit).
312
+ LEG_STATE_ARMED = 'armed'
313
+ LEG_STATE_PENDING_ENTRY = 'pending_entry'
314
+ LEG_STATE_TRIGGERING = 'triggering'
315
+ LEG_STATE_TRIGGERED = 'triggered'
316
+ LEG_STATE_TRIGGERED_FAILED = 'triggered_failed'
317
+ LEG_STATE_TRIGGERED_UNKNOWN = 'triggered_unknown'
318
+ LEG_STATE_ABORTED_PARENT_GONE = 'aborted_parent_gone'
319
+ LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED = 'aborted_parent_never_arrived'
320
+ LEG_STATE_CASCADED_CANCEL = 'cascaded_cancel'
321
+ LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE = 'cascaded_cancel_by_parent_close'
322
+ LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL = 'cascaded_cancel_by_native_sl'
323
+ # Intermediate verification state for ``pending_entry`` legs whose
324
+ # parent ``EntryIntent`` was cancelled but the broker's disposition is
325
+ # **unknown** (network timeout, ambiguous response). The leg is
326
+ # neither terminal nor active: it does not contribute to the
327
+ # ``NativeFailsafeManager`` worst-SL set, does not arm on price ticks,
328
+ # and is excluded from the diff-loop adoption path. The sync engine's
329
+ # ``reconcile()`` cancel-retry-loop resolves it within the stale-grace
330
+ # window (default 10s) by re-invoking ``execute_cancel_with_outcome``;
331
+ # the outcome flips the leg to ``aborted_parent_never_arrived`` (on
332
+ # ``CANCEL_CONFIRMED``) or back to ``pending_entry`` / ``armed`` (on
333
+ # ``ALREADY_FILLED``). Stale-grace expiry promotes the parent to
334
+ # ``DEGRADED_HALT``. See the cancel-tentative state design dossier.
335
+ LEG_STATE_CANCEL_TENTATIVE = 'cancel_tentative'
336
+
337
+ # Terminal states — the row is closed (``closed_ts_ms`` is set) and
338
+ # the engine no longer owns it. Used by both
339
+ # :func:`iter_active_engine_trigger_partial_legs` (to filter them out)
340
+ # and the engine's audit emitters.
341
+ LEG_STATE_TERMINAL: frozenset[str] = frozenset({
342
+ LEG_STATE_TRIGGERED,
343
+ LEG_STATE_ABORTED_PARENT_GONE,
344
+ LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
345
+ LEG_STATE_CASCADED_CANCEL,
346
+ LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
347
+ LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
348
+ })
349
+
350
+ # Active states — the row is alive and the engine state machine owns
351
+ # the next transition. :data:`LEG_STATE_TRIGGERED_FAILED` and
352
+ # :data:`LEG_STATE_TRIGGERED_UNKNOWN` are intermediate (the engine
353
+ # retries / reconciles to either ``triggered`` or back to ``armed``).
354
+ LEG_STATE_ACTIVE: frozenset[str] = frozenset({
355
+ LEG_STATE_ARMED,
356
+ LEG_STATE_PENDING_ENTRY,
357
+ LEG_STATE_TRIGGERING,
358
+ LEG_STATE_TRIGGERED_FAILED,
359
+ LEG_STATE_TRIGGERED_UNKNOWN,
360
+ })
361
+
362
+ # Live states — every non-terminal leg state. Superset of
363
+ # :data:`LEG_STATE_ACTIVE` that additionally includes
364
+ # :data:`LEG_STATE_CANCEL_TENTATIVE`: a tentative leg's row is still
365
+ # open (``closed_ts_ms IS NULL``) and must survive restart replay,
366
+ # but the engine does NOT own its next transition — the sync engine's
367
+ # cancel-retry-loop drives it via ``execute_cancel_with_outcome``.
368
+ # Used by :func:`iter_active_engine_trigger_partial_legs` to include
369
+ # tentative rows in restart rehydration, and by the leg-state update
370
+ # validator to permit transitions into ``cancel_tentative``.
371
+ LEG_STATE_LIVE: frozenset[str] = LEG_STATE_ACTIVE | frozenset({
372
+ LEG_STATE_CANCEL_TENTATIVE,
373
+ })
374
+
375
+ # Canonical ``extras`` keys for an engine-trigger partial bracket leg
376
+ # row. Defined as string constants so a typo at a call site fails the
377
+ # import rather than producing silent dictionary divergence between
378
+ # the persist helper and the recovery / iteration helpers.
379
+ EXTRAS_KEY_LEG_KIND = 'leg_kind'
380
+ EXTRAS_KEY_LEG_STATE = 'leg_state'
381
+ EXTRAS_KEY_TRIGGER_LEVEL = 'trigger_level'
382
+ EXTRAS_KEY_TRIGGER_OFFSET = 'trigger_offset'
383
+ # Trailing-stop activation level (price units). While set, the trail
384
+ # leg is in its pre-activation phase: the engine only watches whether
385
+ # the current price has crossed this level. Cleared (set to ``None``)
386
+ # the moment activation fires, at which point :data:`EXTRAS_KEY_TRIGGER_LEVEL`
387
+ # starts carrying the moving stop level computed from
388
+ # :data:`EXTRAS_KEY_TRIGGER_OFFSET`.
389
+ EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL = 'trail_activation_level'
390
+ # Pre-activation activation OFFSET in price units (i.e.
391
+ # ``trail_points_ticks * mintick``). Present only on a trail leg
392
+ # whose parent entry is still pending — at parent fill time the engine
393
+ # resolves it to an absolute :data:`EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL`.
394
+ EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET = 'trail_activation_offset'
395
+ EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF = 'parent_entry_dispatch_ref'
396
+ EXTRAS_KEY_INTENT_PARTIAL_QTY = 'intent_partial_qty'
397
+ EXTRAS_KEY_PARENT_PINE_ENTRY_ID = 'parent_pine_entry_id'
398
+ EXTRAS_KEY_OCA_GROUP = 'oca_group'
399
+ EXTRAS_KEY_OCA_TYPE = 'oca_type'
400
+ # Wall-clock timestamp (ms) marking when this leg entered the
401
+ # ``cancel_tentative`` state. Persisted on the leg row so that a
402
+ # restart can rehydrate the sync engine's cancel-disposition shadow
403
+ # map without losing the stale-grace deadline. Cleared the moment
404
+ # the leg leaves ``cancel_tentative`` (either back to
405
+ # ``pending_entry`` / ``armed`` on restore, or forward to
406
+ # ``aborted_parent_never_arrived`` on confirmed cancel).
407
+ EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS = 'cancel_tentative_since_ts_ms'
408
+
409
+
410
+ # === Entry-stop watch constants ============================================
411
+
412
+ # ``orders.state`` marker for a synthetic entry-stop WATCH row. A both-set
413
+ # Pine entry (``strategy.entry(limit=, stop=)``) is two OCO legs: the LIMIT
414
+ # leg rests natively as a working order, while the STOP leg is a software
415
+ # price-watch that fires a MARKET order on the stop side. This row carries
416
+ # NO exchange-side order (the native LIMIT and the eventual MARKET have their
417
+ # own rows) — the :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
418
+ # state machine owns it. Mirrors :data:`STATE_PARTIAL_BRACKET_LEG`: the
419
+ # journal / reconcile paths short-circuit on these rows and the actual watch
420
+ # phase lives under :data:`EXTRAS_KEY_ENTRY_STOP_STATE`.
421
+ STATE_ENTRY_STOP_WATCH = 'entry_stop_watch'
422
+
423
+ # ``extras[EXTRAS_KEY_ENTRY_STOP_STATE]`` values — the entry-stop watch state
424
+ # machine's current phase. ``armed`` watches the price against the stop level
425
+ # while the native LIMIT rests. ``cancel_pending`` is latched once the stop is
426
+ # crossed: the engine cancels the native LIMIT and gates the market on the
427
+ # cancel disposition. ``stop_market_pending`` is reached only after the LIMIT
428
+ # cancel is CONFIRMED — the deterministic MARKET client-order-id is persisted
429
+ # before the POST so a restart verifies-before-resends and never double-opens.
430
+ # The three terminal states record which leg of the OCO won.
431
+ ENTRY_STOP_STATE_ARMED = 'armed'
432
+ ENTRY_STOP_STATE_CANCEL_PENDING = 'cancel_pending'
433
+ ENTRY_STOP_STATE_MARKET_PENDING = 'stop_market_pending'
434
+ ENTRY_STOP_STATE_LIMIT_WON = 'limit_won'
435
+ ENTRY_STOP_STATE_STOP_WON = 'stop_won'
436
+ ENTRY_STOP_STATE_ABORTED = 'aborted'
437
+
438
+ # Terminal states — the row is closed and the engine no longer owns it.
439
+ ENTRY_STOP_STATE_TERMINAL: frozenset[str] = frozenset({
440
+ ENTRY_STOP_STATE_LIMIT_WON,
441
+ ENTRY_STOP_STATE_STOP_WON,
442
+ ENTRY_STOP_STATE_ABORTED,
443
+ })
444
+
445
+ # Live states — the row is alive and the engine state machine drives the next
446
+ # transition. ``cancel_pending`` and ``stop_market_pending`` are latched
447
+ # intermediates: on restart the engine re-drives them deterministically
448
+ # (re-issue the idempotent cancel / re-dispatch the idempotent market) rather
449
+ # than re-evaluating the price.
450
+ ENTRY_STOP_STATE_LIVE: frozenset[str] = frozenset({
451
+ ENTRY_STOP_STATE_ARMED,
452
+ ENTRY_STOP_STATE_CANCEL_PENDING,
453
+ ENTRY_STOP_STATE_MARKET_PENDING,
454
+ })
455
+
456
+ # Abortable states — an external cancel / reject of the native LIMIT leg may
457
+ # retire the watch only here. Once the watch has committed to the stop side
458
+ # (``stop_market_pending`` — the deterministic KIND_ENTRY_STOP market id is
459
+ # already persisted and the MARKET is in flight), a delayed broker
460
+ # cancelled/rejected ack for the now-cancelled LIMIT must NOT abort it; that
461
+ # echo would corrupt the persist-first ledger and drop the verify-before-resend
462
+ # watch on restart. ``stop_market_pending`` is therefore deliberately excluded.
463
+ ENTRY_STOP_STATE_ABORTABLE: frozenset[str] = frozenset({
464
+ ENTRY_STOP_STATE_ARMED,
465
+ ENTRY_STOP_STATE_CANCEL_PENDING,
466
+ })
467
+
468
+ # Canonical ``extras`` keys for an entry-stop watch row.
469
+ EXTRAS_KEY_ENTRY_STOP_STATE = 'entry_stop_state'
470
+ # Absolute price the watch fires the market at (the Pine entry's ``stop``).
471
+ EXTRAS_KEY_ENTRY_STOP_LEVEL = 'entry_stop_level'
472
+ # The native LIMIT leg's client-order-id — the leg-scoped cancel target.
473
+ EXTRAS_KEY_ENTRY_STOP_LIMIT_COID = 'entry_stop_limit_coid'
474
+ # The stop-fired MARKET order's deterministic client-order-id. Persisted on
475
+ # the transition into ``stop_market_pending`` (BEFORE the POST) so a restart
476
+ # can verify-before-resend.
477
+ EXTRAS_KEY_ENTRY_STOP_MARKET_COID = 'entry_stop_market_coid'
478
+
479
+
480
+ # === One-way emulation close-leg constants =================================
481
+
482
+ # ``orders.state`` marker for a one-way emulation close-leg row. The row owns no
483
+ # exchange-side order of its own — it records one leg of a ``CloseIntent`` fanned
484
+ # FIFO across a hedging account's legs, so the
485
+ # :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` can re-derive an
486
+ # interrupted fan-out on restart. Like :data:`STATE_PARTIAL_BRACKET_LEG`, this
487
+ # marker tells the journal / reconcile paths to short-circuit: the leg's close
488
+ # FILL itself flows through the normal natural-close path, applied FIFO by
489
+ # ``record_fill`` as a ``LegType.CLOSE``.
490
+ STATE_CLOSE_LEG = 'close_leg'
491
+
492
+ # ``extras[EXTRAS_KEY_CLOSE_LEG_STATE]`` phases of one fanned close leg.
493
+ # ``pending`` — row persisted, the per-leg close has NOT been acked yet.
494
+ # ``dispatched`` — the transport's ``close_leg`` returned without raising
495
+ # (terminal; the row is closed). ``restart_replay`` resumes only ``pending``
496
+ # legs (a crash before the ack) and reconciles each against the live legs
497
+ # before re-dispatching, so an already-executed close is never repeated.
498
+ CLOSE_LEG_STATE_PENDING = 'pending'
499
+ CLOSE_LEG_STATE_DISPATCHED = 'dispatched'
500
+
501
+ # Live (non-terminal) close-leg phases ``restart_replay`` must resume.
502
+ CLOSE_LEG_STATE_LIVE: frozenset[str] = frozenset({CLOSE_LEG_STATE_PENDING})
503
+
504
+ # Canonical ``extras`` keys for a close-leg row.
505
+ EXTRAS_KEY_CLOSE_LEG_STATE = 'close_leg_state'
506
+ # Broker leg id (cTrader ``positionId``) this row closes.
507
+ EXTRAS_KEY_CLOSE_LEG_ID = 'close_leg_id'
508
+ # The owning CloseIntent dispatch's ``KIND_CLOSE`` client-order-id; the per-leg
509
+ # coid is derived deterministically as ``f"{parent}:{leg_id}"`` so a
510
+ # restart-mid-fan never produces two rows for one leg.
511
+ EXTRAS_KEY_CLOSE_PARENT_COID = 'close_parent_coid'
512
+ # Broker-grid integer volume dispatched for this leg (audit + replay residual).
513
+ EXTRAS_KEY_CLOSE_LEG_VOLUME = 'close_leg_volume'
514
+
515
+
516
+ # === One-way emulation residual-open constants =============================
517
+
518
+ # ``orders.state`` marker for a reversal's residual-OPEN intent. Like
519
+ # :data:`STATE_CLOSE_LEG`, the row owns no exchange order and the journal /
520
+ # reconcile / startup-recovery paths short-circuit on it (they only act on
521
+ # ``submitted`` / ``confirmed`` / ``closing`` / ``rejected`` rows). It is a
522
+ # persist-first breadcrumb written by
523
+ # :meth:`~pynecore.core.broker.one_way_emulator.OneWayEmulator.run_reversal`
524
+ # BEFORE the FIFO closes, so a crash in the window between the closes landing
525
+ # and the residual ``place_leg`` persisting its own entry row does not lose the
526
+ # residual open. :meth:`OneWayEmulator.restart_replay` reconciles it against the
527
+ # residual entry row (re-dispatching the open only when that row never landed)
528
+ # and then clears it.
529
+ STATE_RESIDUAL_OPEN = 'residual_open'
530
+
531
+ # Canonical ``extras`` keys for a residual-open row. The deterministic
532
+ # ``KIND_ENTRY`` coid of the residual ``place_leg`` (existence proves the open
533
+ # already entered the persist-first entry path / 2.x recovery), plus the
534
+ # envelope fields needed to rebuild the dispatch on replay (the residual is
535
+ # always a MARKET entry; the early resting-order branch never persists one).
536
+ EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID = 'residual_open_entry_coid'
537
+ EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG = 'residual_open_run_tag'
538
+ EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS = 'residual_open_bar_ts_ms'
539
+ EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ = 'residual_open_retry_seq'
540
+
541
+
542
+ # === One-way emulation bracket-ownership constants =========================
543
+
544
+ # ``orders.state`` marker for a per-leg bracket-ownership row. Like
545
+ # :data:`STATE_CLOSE_LEG`, the row owns no exchange order: it records that one
546
+ # exit's native TP/SL/trailing bracket was replicated onto one broker leg of a
547
+ # hedging position, so the
548
+ # :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` can clear ONLY
549
+ # the legs a given exit owns (the plugin's broadcast clear strips brackets a
550
+ # different exit set) and re-assert / release them on restart. The marker tells
551
+ # the journal / reconcile paths to short-circuit, exactly as the close-leg and
552
+ # partial-bracket-leg markers do.
553
+ STATE_BRACKET_OWN = 'bracket_own'
554
+
555
+ # ``extras[EXTRAS_KEY_BRACKET_OWN_STATE]`` phases of one owned leg's bracket.
556
+ # ``active`` — the bracket is replicated onto this leg (steady state). ``clearing``
557
+ # — a clear is in flight: the row is marked PERSIST-FIRST before the amend-to-
558
+ # clear, mirroring the close-leg ``pending`` -> ``dispatched`` two-phase, so an
559
+ # ambiguous (timed-out) clear re-runs the CLEAR on restart instead of
560
+ # re-asserting the original levels (which would resurrect a bracket the script
561
+ # cancelled). ``released`` — the bracket was cleared from this leg (terminal; the
562
+ # row is closed). ``restart_replay`` re-asserts ``active`` rows whose leg is
563
+ # still open, finishes ``clearing`` rows by re-clearing them, and releases any
564
+ # whose leg vanished.
565
+ BRACKET_OWN_STATE_ACTIVE = 'active'
566
+ BRACKET_OWN_STATE_CLEARING = 'clearing'
567
+ BRACKET_OWN_STATE_RELEASED = 'released'
568
+
569
+ # Live (non-terminal) bracket-ownership phases ``restart_replay`` re-asserts /
570
+ # finishes. ``clearing`` stays live so a clear interrupted by a crash or an
571
+ # ambiguous timeout is resumed (re-cleared, then released), never left dangling.
572
+ BRACKET_OWN_STATE_LIVE: frozenset[str] = frozenset(
573
+ {BRACKET_OWN_STATE_ACTIVE, BRACKET_OWN_STATE_CLEARING}
574
+ )
575
+
576
+ # Canonical ``extras`` keys for a bracket-ownership row.
577
+ EXTRAS_KEY_BRACKET_OWN_STATE = 'bracket_own_state'
578
+ # Broker leg id (cTrader ``positionId``) this exit's bracket is replicated onto.
579
+ EXTRAS_KEY_BRACKET_OWN_LEG_ID = 'bracket_own_leg_id'
580
+ # The owning exit's bracket-attach dispatch coid (``KIND_EXIT_SL``); the per-leg
581
+ # row coid is derived as ``f"{attach_coid}:{leg_id}"`` so a re-attach upserts the
582
+ # SAME row and two different exits get disjoint coid namespaces.
583
+ EXTRAS_KEY_BRACKET_OWN_ATTACH_COID = 'bracket_own_attach_coid'
584
+ # The amend-to-clear dispatch coid (``KIND_CANCEL``) recorded PERSIST-FIRST when a
585
+ # row enters ``clearing``, so a restart re-CLEARS under the SAME clear coid instead
586
+ # of the attach coid. ``amend_bracket`` is idempotent on ``coid`` (the contract
587
+ # permits a plugin to dedup a repeated coid), so replaying a clear under the attach
588
+ # coid could be swallowed as a duplicate attach, leaving the bracket armed while the
589
+ # row is released.
590
+ EXTRAS_KEY_BRACKET_OWN_CLEAR_COID = 'bracket_own_clear_coid'
591
+ # Pine-unit protective levels last replicated onto the leg (audit + modify diff).
592
+ EXTRAS_KEY_BRACKET_OWN_TP = 'bracket_own_tp'
593
+ EXTRAS_KEY_BRACKET_OWN_SL = 'bracket_own_sl'
594
+ # Absolute trailing-stop activation price (``ExitIntent.trail_price``). Persisted
595
+ # so a restart can faithfully rebuild a pending-trailing exit's Pine ``Order``;
596
+ # ``trail_offset`` alone cannot distinguish a pre-activation trail from an active
597
+ # one. Absent on rows written before this key existed — reconstruct trail_price
598
+ # as ``None`` in that case (never tear the bracket down over the gap).
599
+ EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE = 'bracket_own_trail_price'
600
+ EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET = 'bracket_own_trail_offset'
601
+ # The owning exit's OCA group identity (``ExitIntent.oca_name`` /
602
+ # ``oca_type``). Persisted so a restart rebuilds the Pine ``Order`` with the same
603
+ # group it was emitted under — otherwise the reconstructed exit carries no OCA
604
+ # group and an explicit ``oca_type='cancel'`` cross-bracket cascade silently
605
+ # stops firing across the restart. Absent on rows written before these keys
606
+ # existed — reconstruct as ``None`` in that case (a single-member synthetic
607
+ # reduce group is a cascade no-op, so the gap only ever loses an explicit group).
608
+ EXTRAS_KEY_BRACKET_OWN_OCA_NAME = 'bracket_own_oca_name'
609
+ EXTRAS_KEY_BRACKET_OWN_OCA_TYPE = 'bracket_own_oca_type'
610
+
611
+
612
+ # === Entry order row lifecycle =============================================
613
+
614
+ def create_entry_order_row(
615
+ store: 'RunContext',
616
+ *,
617
+ coid: str,
618
+ symbol: str,
619
+ side: str,
620
+ qty: float,
621
+ intent_key: str,
622
+ pine_entry_id: str,
623
+ kind: str,
624
+ order_type: str,
625
+ ) -> None:
626
+ """Insert the initial ``submitted`` row for an entry dispatch.
627
+
628
+ Replaces the manual
629
+ ``store.upsert_order(coid, ..., extras={'kind': ..., 'order_type': ...})``
630
+ that every plugin re-implements. The function does **not** write an
631
+ audit event — the orchestrator does that *after* the upsert so a
632
+ crash between the two does not leave an event without its row.
633
+
634
+ :param store: The active run context (`plugin.store_ctx`).
635
+ :param coid: The dispatch's canonical client-order-id.
636
+ :param symbol: Exchange-side symbol (epic for Capital.com, etc.).
637
+ :param side: ``'buy'`` or ``'sell'`` — Pine intent's side.
638
+ :param qty: Already quantized to the broker's lot step.
639
+ :param intent_key: The Pine intent's diff key (e.g. ``pine_id``).
640
+ :param pine_entry_id: The Pine ``strategy.entry(id=...)`` value.
641
+ :param kind: One of :data:`ENTRY_KIND_POSITION`,
642
+ :data:`ENTRY_KIND_WORKING`. Decides how downstream recovery and
643
+ activity reconcile interpret the row.
644
+ :param order_type: The ``OrderType`` enum's ``.value`` (``'market'``,
645
+ ``'limit'``, ``'stop'``). Persisted into extras so recovery can
646
+ replay an entry without re-deriving it from the intent.
647
+ """
648
+ if kind not in (ENTRY_KIND_POSITION, ENTRY_KIND_WORKING):
649
+ raise ValueError(
650
+ f"create_entry_order_row: kind must be one of "
651
+ f"{{ENTRY_KIND_POSITION, ENTRY_KIND_WORKING}}, got {kind!r}"
652
+ )
653
+ # Reopen a previously-closed row sharing this COID before writing the
654
+ # fresh submitted fields. ``DispatchEnvelope.client_order_id`` is a pure
655
+ # function of (run_tag, pine_id, bar_ts_ms, kind, retry_seq), so a Pine
656
+ # strategy that cancels an order and re-creates it under the same id
657
+ # within the same bar produces an *identical* COID. The first lifecycle
658
+ # closed the row (``closed_ts_ms`` set); ``upsert_order`` then takes its
659
+ # UPDATE path, which does not touch ``closed_ts_ms`` — leaving the
660
+ # re-created order invisible to ``iter_live_orders`` and therefore to the
661
+ # cancel sweep, reconcile, and fill-fallback. A later ``strategy.cancel``
662
+ # finds no live target and degrades to a no-op while the order is still
663
+ # live on the exchange. Clearing ``closed_ts_ms`` and resetting the
664
+ # per-lifecycle fields (exchange id, fill) restores true insert semantics
665
+ # for the fresh submission. Mirrors the plugin-side TP/SL reopen guard.
666
+ existing = store.get_order(coid)
667
+ if existing is not None and existing.closed_ts_ms is not None:
668
+ store.reopen_order(coid)
669
+ store.upsert_order(coid, exchange_order_id=None, filled_qty=0.0)
670
+ store.upsert_order(
671
+ coid,
672
+ symbol=symbol,
673
+ side=side,
674
+ qty=qty,
675
+ state=STATE_SUBMITTED,
676
+ intent_key=intent_key,
677
+ pine_entry_id=pine_entry_id,
678
+ extras={'kind': kind, 'order_type': order_type},
679
+ )
680
+
681
+
682
+ def record_server_ref(
683
+ store: 'RunContext',
684
+ *,
685
+ coid: str,
686
+ deal_reference: str,
687
+ kind: str,
688
+ order_type: str,
689
+ ) -> None:
690
+ """Record the exchange's submission reference + advance state.
691
+
692
+ Two writes in a defined order:
693
+
694
+ 1. ``order_refs`` row keyed by ``'deal_reference'`` so activity /
695
+ reconcile can resolve the reference back to the COID with a
696
+ single indexed lookup.
697
+ 2. ``orders.extras`` updated to mirror the reference *together
698
+ with* ``orders.state`` advanced to
699
+ :data:`STATE_SERVER_REF_SEEN` — single
700
+ :meth:`RunContext.upsert_order` transaction, so state + extras
701
+ can never disagree.
702
+
703
+ Crash-safety: if the process crashes between (1) and (2), the row
704
+ is still in :data:`STATE_SUBMITTED` (pending), and the resume hook
705
+ sees the ``deal_reference`` via the ``order_refs`` table —
706
+ :func:`~pynecore.core.broker.journal._collect_refs_for` materialises
707
+ it from there. That is the contract :func:`find_pending_dispatch`
708
+ relies on.
709
+
710
+ :param store: The active run context.
711
+ :param coid: The dispatch's COID.
712
+ :param deal_reference: Server-allocated reference for the POST.
713
+ :param kind: Same value originally passed to
714
+ :func:`create_entry_order_row`. Repeated here because
715
+ ``upsert_order(extras=...)`` overwrites the whole dict.
716
+ :param order_type: Same value originally passed to
717
+ :func:`create_entry_order_row`. Repeated for the same reason.
718
+ """
719
+ store.add_ref(coid, 'deal_reference', deal_reference)
720
+ store.upsert_order(
721
+ coid,
722
+ state=STATE_SERVER_REF_SEEN,
723
+ extras={
724
+ 'kind': kind,
725
+ 'order_type': order_type,
726
+ 'deal_reference': deal_reference,
727
+ },
728
+ )
729
+
730
+
731
+ def mark_confirmed_with_fill(
732
+ store: 'RunContext',
733
+ *,
734
+ coid: str,
735
+ exchange_id: str | None,
736
+ is_filled: bool,
737
+ filled_qty: float,
738
+ fill_price: float | None,
739
+ ) -> None:
740
+ """Promote the row to ``confirmed``, optionally recording a fill.
741
+
742
+ A MARKET entry that fills immediately needs four facts persisted in
743
+ a single logical step:
744
+
745
+ - ``order_refs`` row keyed by ``'deal_id'`` (so activity tail rows
746
+ that carry only the deal id can map back).
747
+ - ``orders.exchange_order_id`` populated.
748
+ - ``orders.state`` = :data:`STATE_CONFIRMED`.
749
+ - ``orders.filled_qty`` set and ``extras['confirm_level']`` set
750
+ from the confirm response, as a recovery fallback when the
751
+ ``/history/activity`` row arrives with ``level=0`` and the
752
+ ``/positions`` snapshot is also empty for this deal id.
753
+
754
+ LIMIT / STOP entries call this with ``is_filled=False``; only the
755
+ first three facts apply.
756
+
757
+ :param store: The active run context.
758
+ :param coid: The dispatch's COID.
759
+ :param exchange_id: Exchange-allocated id (``dealId`` for
760
+ Capital.com). May be ``None`` if confirm returns no id — the
761
+ function still advances state but skips the id-related writes.
762
+ :param is_filled: ``True`` only for MARKET-side fills that confirm
763
+ as OPEN. LIMIT / STOP submissions land as live working orders
764
+ and pass ``False`` here; the fill arrives later via the
765
+ activity stream.
766
+ :param filled_qty: Confirmed fill quantity. Ignored when
767
+ ``is_filled`` is ``False``.
768
+ :param fill_price: Confirm-side fill price. Persisted under
769
+ ``extras['confirm_level']`` only when ``is_filled`` is ``True``
770
+ and the value is strictly positive — a zero/negative level is
771
+ a no-quote artefact and would corrupt the recovery fallback.
772
+
773
+ Crash-safety: ``exchange_order_id``, ``state``, ``filled_qty``, and
774
+ the merged ``extras`` (with ``confirm_level``) are written in a
775
+ *single* :meth:`RunContext.upsert_order` transaction, so a crash
776
+ cannot leave the row in :data:`STATE_CONFIRMED` (terminal) without
777
+ its fill details. The ``deal_id`` ref is written first as a separate
778
+ transaction — if a crash occurs between the ref write and the
779
+ consolidated update, the row's state is still
780
+ :data:`STATE_SERVER_REF_SEEN` (pending), so
781
+ :func:`find_pending_dispatch` re-yields it and the resume hook can
782
+ rebuild the confirmation from the ``deal_id`` already in
783
+ ``order_refs``.
784
+ """
785
+ if exchange_id:
786
+ store.add_ref(coid, 'deal_id', exchange_id)
787
+
788
+ fields: dict[str, Any] = {'state': STATE_CONFIRMED}
789
+ if exchange_id:
790
+ fields['exchange_order_id'] = exchange_id
791
+ if is_filled:
792
+ fields['filled_qty'] = filled_qty
793
+ if fill_price is not None and fill_price > 0.0:
794
+ existing = store.get_order(coid)
795
+ merged = dict(existing.extras or {}) if existing is not None else {}
796
+ merged['confirm_level'] = fill_price
797
+ fields['extras'] = merged
798
+ store.upsert_order(coid, **fields)
799
+
800
+
801
+ def mark_disposition_unknown(
802
+ store: 'RunContext',
803
+ *,
804
+ coid: str,
805
+ ) -> None:
806
+ """Flip the row to :data:`STATE_DISPOSITION_UNKNOWN`.
807
+
808
+ Used when a POST times out or the response is missing the server
809
+ reference. The row is *not* deleted — recovery on the next restart
810
+ re-evaluates it against the exchange's authoritative view.
811
+ """
812
+ store.set_order_state(coid, STATE_DISPOSITION_UNKNOWN)
813
+
814
+
815
+ def mark_rejected(
816
+ store: 'RunContext',
817
+ *,
818
+ coid: str,
819
+ ) -> None:
820
+ """Flip the row to :data:`STATE_REJECTED` (terminal).
821
+
822
+ Used when the exchange returns a definitive reject — confirm
823
+ ``REJECTED``, a 4xx response, or a synchronous reason string the
824
+ plugin maps to ``ExchangeOrderRejectedError``. The row stays in
825
+ place for audit; the engine clears its intent slot on the next
826
+ sync.
827
+ """
828
+ store.set_order_state(coid, STATE_REJECTED)
829
+
830
+
831
+ # === Recovery query ========================================================
832
+
833
+ def find_pending_dispatch(store: 'RunContext') -> Iterator['OrderRow']:
834
+ """Yield live rows whose dispatch the journal still owns.
835
+
836
+ A row is *pending* when its state is in
837
+ :data:`PENDING_DISPATCH_STATES`. The journal's
838
+ :meth:`~pynecore.core.broker.journal.DispatchJournal.recover_pending`
839
+ iterates these on startup and asks the plugin's
840
+ ``resume_pending_dispatch`` hook for a verdict.
841
+
842
+ The query relies on the partial ``idx_orders_live`` index, so the
843
+ cost is O(log n) per call.
844
+ """
845
+ for row in store.iter_live_orders():
846
+ if row.state in PENDING_DISPATCH_STATES:
847
+ yield row
848
+
849
+
850
+ # === Close lifecycle =======================================================
851
+
852
+ def create_close_target_row(
853
+ store: 'RunContext',
854
+ *,
855
+ coid: str,
856
+ symbol: str,
857
+ side: str,
858
+ qty: float,
859
+ intent_key: str,
860
+ kind: str,
861
+ pine_entry_id: str | None = None,
862
+ extra_payload: Mapping[str, Any] | None = None,
863
+ ) -> None:
864
+ """Insert the initial ``submitted`` row for a close dispatch.
865
+
866
+ Both full-close and partial-close dispatches start with the same
867
+ persist-first row; the ``kind`` discriminator (:data:`KIND_FULL_CLOSE`
868
+ vs :data:`KIND_PARTIAL_CLOSE`) decides the downstream lifecycle and
869
+ the recovery contract.
870
+
871
+ :param store: The active run context.
872
+ :param coid: The dispatch's COID
873
+ (``envelope.client_order_id(KIND_CLOSE)``).
874
+ :param symbol: Exchange-side symbol (epic).
875
+ :param side: Side of the closing leg — for a full close this is
876
+ the side of the position being closed; for a partial close
877
+ this is the opposite-direction emulation side.
878
+ :param qty: Quantity to close, already quantized.
879
+ :param intent_key: The Pine intent's diff key.
880
+ :param kind: :data:`KIND_FULL_CLOSE` or :data:`KIND_PARTIAL_CLOSE`.
881
+ :param pine_entry_id: Optional Pine ``strategy.entry(id=...)`` value
882
+ when the close is tied to a single named entry. ``None`` when
883
+ the close sweeps multiple positions (e.g. full close across
884
+ ids).
885
+ :param extra_payload: Optional extras to merge in alongside the
886
+ canonical ``kind`` key. Use for plugin-specific bookkeeping
887
+ the recovery contract needs (e.g. ``pre_total_units``,
888
+ ``intent_units`` for the partial-close emulation).
889
+ """
890
+ if kind not in CLOSE_KINDS:
891
+ raise ValueError(
892
+ f"create_close_target_row: kind must be one of {{KIND_FULL_CLOSE, "
893
+ f"KIND_PARTIAL_CLOSE}}, got {kind!r}"
894
+ )
895
+ extras: dict[str, Any] = {'kind': kind}
896
+ if extra_payload:
897
+ extras.update(extra_payload)
898
+ store.upsert_order(
899
+ coid,
900
+ symbol=symbol,
901
+ side=side,
902
+ qty=qty,
903
+ state=STATE_SUBMITTED,
904
+ intent_key=intent_key,
905
+ pine_entry_id=pine_entry_id,
906
+ extras=extras,
907
+ )
908
+
909
+
910
+ def record_close_server_ref(
911
+ store: 'RunContext',
912
+ *,
913
+ coid: str,
914
+ deal_reference: str,
915
+ kind: str,
916
+ extra_payload: Mapping[str, Any] | None = None,
917
+ ) -> None:
918
+ """Record the partial-close POST's server reference + advance state.
919
+
920
+ Only the partial-close path issues a POST that allocates a
921
+ ``dealReference``; the full-close DELETE returns no such id, so
922
+ this helper is not called there. The two-write order matches
923
+ :func:`record_server_ref`: ``order_refs`` first, then a consolidated
924
+ ``upsert_order`` that mirrors the reference into ``extras`` and
925
+ advances state to :data:`STATE_SERVER_REF_SEEN`.
926
+
927
+ :param store: The active run context.
928
+ :param coid: The dispatch's COID.
929
+ :param deal_reference: Server-allocated reference for the POST.
930
+ :param kind: Must be :data:`KIND_PARTIAL_CLOSE`. Re-stated because
931
+ ``upsert_order(extras=...)`` overwrites the whole dict.
932
+ :param extra_payload: Optional extras to preserve through the
933
+ state advance (e.g. ``pre_total_units``, ``intent_units``).
934
+ """
935
+ if kind != KIND_PARTIAL_CLOSE:
936
+ raise ValueError(
937
+ f"record_close_server_ref: kind must be {KIND_PARTIAL_CLOSE!r}, "
938
+ f"got {kind!r}"
939
+ )
940
+ store.add_ref(coid, 'deal_reference', deal_reference)
941
+ # ``upsert_order(extras=...)`` overwrites the whole dict, so read the
942
+ # current extras first and merge: plugin-side context written between
943
+ # row creation and this helper (e.g. ``pre_total_units`` /
944
+ # ``intent_units`` from the partial-close emulation's pre-snapshot)
945
+ # must survive the state advance so recovery has them.
946
+ existing = store.get_order(coid)
947
+ extras: dict[str, Any] = dict(existing.extras or {}) if existing else {}
948
+ extras['kind'] = kind
949
+ extras['deal_reference'] = deal_reference
950
+ if extra_payload:
951
+ extras.update(extra_payload)
952
+ store.upsert_order(
953
+ coid,
954
+ state=STATE_SERVER_REF_SEEN,
955
+ extras=extras,
956
+ )
957
+
958
+
959
+ def mark_closing(
960
+ store: 'RunContext',
961
+ *,
962
+ coid: str,
963
+ kind: str,
964
+ targets: list[str],
965
+ extra_payload: Mapping[str, Any] | None = None,
966
+ ) -> None:
967
+ """Flip a full-close row to :data:`STATE_CLOSING` and pin its targets.
968
+
969
+ Called after every DELETE has been issued but before the activity
970
+ stream has confirmed the resulting fills. The ``targets`` list is
971
+ persisted under ``extras['targets']`` so a restart-mid-stream can
972
+ reconcile each ``dealId`` against the live positions snapshot.
973
+
974
+ :param store: The active run context.
975
+ :param coid: The dispatch's COID.
976
+ :param kind: Must be :data:`KIND_FULL_CLOSE`. Partial-close uses
977
+ :func:`mark_close_completed` directly because its emulation
978
+ finishes synchronously inside the POST.
979
+ :param targets: Exchange ``dealId`` strings the DELETE chain
980
+ targeted. Empty list is a no-op close (the engine should have
981
+ elided the dispatch, but this is harmless).
982
+ :param extra_payload: Optional extras to preserve.
983
+ """
984
+ if kind != KIND_FULL_CLOSE:
985
+ raise ValueError(
986
+ f"mark_closing: kind must be {KIND_FULL_CLOSE!r}, got {kind!r}"
987
+ )
988
+ extras: dict[str, Any] = {
989
+ 'kind': kind,
990
+ 'targets': list(targets),
991
+ }
992
+ if extra_payload:
993
+ extras.update(extra_payload)
994
+ store.upsert_order(
995
+ coid,
996
+ state=STATE_CLOSING,
997
+ extras=extras,
998
+ )
999
+
1000
+
1001
+ def mark_close_completed(
1002
+ store: 'RunContext',
1003
+ *,
1004
+ coid: str,
1005
+ kind: str,
1006
+ extra_payload: Mapping[str, Any] | None = None,
1007
+ ) -> None:
1008
+ """Finalise a close dispatch row's state to ``confirmed``.
1009
+
1010
+ For full close, called once every target's vanished position has
1011
+ been observed (or the recovery path has decided the targets are
1012
+ gone). For partial close, called when the POST + post-snapshot
1013
+ reconcile confirm the unit-count delta matches.
1014
+
1015
+ Does **not** call :meth:`RunContext.close_order` — the journal
1016
+ issues that as a separate step *after* it has emitted the
1017
+ ``confirmed`` audit event, so the event order is
1018
+ ``dispatch_submitted → deal_reference_seen → confirmed →
1019
+ order_closed``.
1020
+
1021
+ :param store: The active run context.
1022
+ :param coid: The dispatch's COID.
1023
+ :param kind: :data:`KIND_FULL_CLOSE` or :data:`KIND_PARTIAL_CLOSE`.
1024
+ :param extra_payload: Optional extras to preserve.
1025
+ """
1026
+ if kind not in CLOSE_KINDS:
1027
+ raise ValueError(
1028
+ f"mark_close_completed: kind must be one of {{KIND_FULL_CLOSE, "
1029
+ f"KIND_PARTIAL_CLOSE}}, got {kind!r}"
1030
+ )
1031
+ fields: dict[str, Any] = {'state': STATE_CONFIRMED}
1032
+ if extra_payload:
1033
+ existing = store.get_order(coid)
1034
+ merged = dict(existing.extras or {}) if existing is not None else {}
1035
+ merged.update(extra_payload)
1036
+ fields['extras'] = merged
1037
+ store.upsert_order(coid, **fields)
1038
+
1039
+
1040
+ # === Cancel lifecycle ======================================================
1041
+
1042
+ def create_cancel_command_row(
1043
+ store: 'RunContext',
1044
+ *,
1045
+ coid: str,
1046
+ symbol: str,
1047
+ side: str,
1048
+ qty: float,
1049
+ intent_key: str,
1050
+ pine_entry_id: str | None = None,
1051
+ from_entry: str | None = None,
1052
+ target_coids: list[str] | None = None,
1053
+ extra_payload: Mapping[str, Any] | None = None,
1054
+ ) -> None:
1055
+ """Insert the initial ``submitted`` row for a cancel dispatch.
1056
+
1057
+ A cancel row is the per-dispatch audit trail for a ``CancelIntent``:
1058
+ it carries the list of target COIDs the per-target loop will sweep,
1059
+ so a mid-loop crash can resume cleanly. The row itself does not
1060
+ land at the exchange — only its targets do (via DELETE / PUT-null).
1061
+
1062
+ :param store: The active run context.
1063
+ :param coid: The cancel dispatch's COID
1064
+ (``envelope.client_order_id(KIND_CANCEL)``).
1065
+ :param symbol: Exchange-side symbol of the targets.
1066
+ :param side: Side of the targets (uniform across a single dispatch).
1067
+ :param qty: Aggregate quantity of the targets — for diagnostics
1068
+ only; the recovery contract uses ``target_coids`` directly.
1069
+ :param intent_key: The Pine intent's diff key.
1070
+ :param pine_entry_id: ``strategy.entry(id=...)`` value the cancel
1071
+ addresses, or ``None`` for cross-entry cancels.
1072
+ :param from_entry: ``ExitIntent`` ``from_entry`` echo when the
1073
+ cancel sweeps a bracket leg, ``None`` otherwise.
1074
+ :param target_coids: COIDs the per-target loop intends to sweep.
1075
+ Persisted under ``extras['target_coids']`` so recovery can
1076
+ re-evaluate after a mid-loop crash. Empty list is permitted —
1077
+ the journal will still emit the audit trail and finalise the
1078
+ row with :func:`mark_cancel_completed`.
1079
+ :param extra_payload: Optional extras to merge in.
1080
+ """
1081
+ extras: dict[str, Any] = {
1082
+ 'kind': KIND_CANCEL,
1083
+ 'target_coids': list(target_coids or ()),
1084
+ }
1085
+ if extra_payload:
1086
+ extras.update(extra_payload)
1087
+ store.upsert_order(
1088
+ coid,
1089
+ symbol=symbol,
1090
+ side=side,
1091
+ qty=qty,
1092
+ state=STATE_SUBMITTED,
1093
+ intent_key=intent_key,
1094
+ pine_entry_id=pine_entry_id,
1095
+ from_entry=from_entry,
1096
+ extras=extras,
1097
+ )
1098
+
1099
+
1100
+ def mark_cancel_completed(
1101
+ store: 'RunContext',
1102
+ *,
1103
+ coid: str,
1104
+ reason_path: str,
1105
+ extra_payload: Mapping[str, Any] | None = None,
1106
+ ) -> None:
1107
+ """Finalise a cancel command row's state to ``confirmed``.
1108
+
1109
+ Called after the per-target loop has finished. ``reason_path``
1110
+ records why the cancel resolved this way (``'deleted'`` when the
1111
+ targets were swept, ``'already_gone'`` when every target had
1112
+ vanished benignly, ``'noop'`` when nothing matched). The value is
1113
+ preserved in ``extras['reason_path']`` for forensics. Does **not**
1114
+ call :meth:`RunContext.close_order` — see
1115
+ :func:`mark_close_completed` for the rationale.
1116
+
1117
+ :param store: The active run context.
1118
+ :param coid: The cancel dispatch's COID.
1119
+ :param reason_path: One of ``'deleted'``, ``'already_gone'``,
1120
+ ``'noop'`` (the canonical :class:`CancelOutcome.reason_path`
1121
+ values).
1122
+ :param extra_payload: Optional extras to preserve.
1123
+ """
1124
+ existing = store.get_order(coid)
1125
+ merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
1126
+ merged['reason_path'] = reason_path
1127
+ if extra_payload:
1128
+ merged.update(extra_payload)
1129
+ store.upsert_order(
1130
+ coid,
1131
+ state=STATE_CONFIRMED,
1132
+ extras=merged,
1133
+ )
1134
+
1135
+
1136
+ # === Modify lifecycle ======================================================
1137
+
1138
+ def create_modify_entry_row(
1139
+ store: 'RunContext',
1140
+ *,
1141
+ coid: str,
1142
+ target_coid: str,
1143
+ symbol: str,
1144
+ side: str,
1145
+ qty: float,
1146
+ intent_key: str,
1147
+ new_level: float,
1148
+ pine_entry_id: str | None = None,
1149
+ extra_payload: Mapping[str, Any] | None = None,
1150
+ ) -> None:
1151
+ """Insert the initial ``submitted`` row for a working-order amend.
1152
+
1153
+ The amend dispatch produces its own COID + audit row separate from
1154
+ the working order it mutates. ``target_coid`` and ``new_level``
1155
+ travel through ``extras`` so a restart-mid-amend can verify the
1156
+ broker landed the change.
1157
+
1158
+ :param store: The active run context.
1159
+ :param coid: The amend dispatch's COID
1160
+ (``envelope.client_order_id(KIND_MODIFY)`` for the new envelope).
1161
+ :param target_coid: COID of the working order being amended.
1162
+ :param symbol: Exchange-side symbol.
1163
+ :param side: Side of the target order.
1164
+ :param qty: Order quantity (unchanged by the amend).
1165
+ :param intent_key: The Pine intent's diff key.
1166
+ :param new_level: Requested new ``level`` for the working order.
1167
+ :param pine_entry_id: ``strategy.entry(id=...)`` echo.
1168
+ :param extra_payload: Optional extras to merge in.
1169
+ """
1170
+ extras: dict[str, Any] = {
1171
+ 'kind': KIND_MODIFY_ENTRY,
1172
+ 'target_coid': target_coid,
1173
+ 'new_level': new_level,
1174
+ }
1175
+ if extra_payload:
1176
+ extras.update(extra_payload)
1177
+ store.upsert_order(
1178
+ coid,
1179
+ symbol=symbol,
1180
+ side=side,
1181
+ qty=qty,
1182
+ state=STATE_SUBMITTED,
1183
+ intent_key=intent_key,
1184
+ pine_entry_id=pine_entry_id,
1185
+ extras=extras,
1186
+ )
1187
+
1188
+
1189
+ def create_modify_exit_row(
1190
+ store: 'RunContext',
1191
+ *,
1192
+ coid: str,
1193
+ target_coid: str,
1194
+ symbol: str,
1195
+ side: str,
1196
+ qty: float,
1197
+ intent_key: str,
1198
+ new_tp: float | None,
1199
+ new_sl: float | None,
1200
+ new_trail: float | None,
1201
+ new_trail_price: float | None = None,
1202
+ pine_entry_id: str | None = None,
1203
+ from_entry: str | None = None,
1204
+ extra_payload: Mapping[str, Any] | None = None,
1205
+ ) -> None:
1206
+ """Insert the initial ``submitted`` row for a position bracket amend.
1207
+
1208
+ Mirrors :func:`create_modify_entry_row` but for ``ExitIntent``
1209
+ amends: the target is the entry-row representing the position and
1210
+ the requested change covers TP / SL / trailing levels. Recovery
1211
+ reads ``new_tp`` / ``new_sl`` / ``new_trail`` / ``new_trail_price``
1212
+ from ``extras`` and compares them against the post-amend snapshot.
1213
+
1214
+ ``new_trail_price`` is Pine's trailing-stop *activation* price
1215
+ (``strategy.exit(trail_price=...)``). When both ``new_trail`` and
1216
+ ``new_trail_price`` are set the bracket is **pending trailing** —
1217
+ the broker carries no native trailing stop yet (the local
1218
+ activation monitor will PUT one once price crosses the threshold),
1219
+ so the post-amend snapshot legitimately shows ``trailingStop=False``
1220
+ and recovery must NOT require ``trailingStop=True`` to declare the
1221
+ amend landed. Persisting the activation price is what lets the
1222
+ snapshot verdict distinguish the two trailing shapes.
1223
+
1224
+ Synthetic bracket leg rows (``leg_kind`` ``'tp'`` / ``'sl'``)
1225
+ remain under the plugin's leg state machine for M4; this helper
1226
+ persists only the entry-side audit trail.
1227
+ """
1228
+ extras: dict[str, Any] = {
1229
+ 'kind': KIND_MODIFY_EXIT,
1230
+ 'target_coid': target_coid,
1231
+ 'new_tp': new_tp,
1232
+ 'new_sl': new_sl,
1233
+ 'new_trail': new_trail,
1234
+ 'new_trail_price': new_trail_price,
1235
+ }
1236
+ if extra_payload:
1237
+ extras.update(extra_payload)
1238
+ store.upsert_order(
1239
+ coid,
1240
+ symbol=symbol,
1241
+ side=side,
1242
+ qty=qty,
1243
+ state=STATE_SUBMITTED,
1244
+ intent_key=intent_key,
1245
+ pine_entry_id=pine_entry_id,
1246
+ from_entry=from_entry,
1247
+ extras=extras,
1248
+ )
1249
+
1250
+
1251
+ def mark_modify_completed(
1252
+ store: 'RunContext',
1253
+ *,
1254
+ coid: str,
1255
+ extra_payload: Mapping[str, Any] | None = None,
1256
+ ) -> None:
1257
+ """Finalise a modify dispatch row's state to ``confirmed``.
1258
+
1259
+ Both :data:`KIND_MODIFY_ENTRY` and :data:`KIND_MODIFY_EXIT` use
1260
+ this terminal helper. The target order it amended is untouched;
1261
+ only the modify command row transitions. Does **not** call
1262
+ :meth:`RunContext.close_order` — see :func:`mark_close_completed`
1263
+ for the rationale.
1264
+
1265
+ :param store: The active run context.
1266
+ :param coid: The modify dispatch's COID.
1267
+ :param extra_payload: Optional extras to preserve (e.g. the
1268
+ amended values echoed back from the confirm response).
1269
+ """
1270
+ fields: dict[str, Any] = {'state': STATE_CONFIRMED}
1271
+ if extra_payload:
1272
+ existing = store.get_order(coid)
1273
+ merged = dict(existing.extras or {}) if existing is not None else {}
1274
+ merged.update(extra_payload)
1275
+ fields['extras'] = merged
1276
+ store.upsert_order(coid, **fields)
1277
+
1278
+
1279
+ # === Reconcile-path terminal helpers =======================================
1280
+
1281
+ def mark_reconcile_filled(
1282
+ store: 'RunContext',
1283
+ *,
1284
+ coid: str,
1285
+ filled_qty: float,
1286
+ new_state: str,
1287
+ extras_patch: Mapping[str, Any] | None,
1288
+ ) -> None:
1289
+ """Promote a row to ``confirmed`` from a reconcile-path observation.
1290
+
1291
+ Used by :meth:`~pynecore.core.broker.journal.DispatchJournal.apply_reconcile_outcome`
1292
+ for the working→position case: a row sitting in
1293
+ :data:`STATE_SERVER_REF_SEEN` is observed in the live positions
1294
+ snapshot, so the journal records the fill and flips
1295
+ ``extras['kind']`` from :data:`ENTRY_KIND_WORKING` to
1296
+ :data:`ENTRY_KIND_POSITION` in a single transaction.
1297
+
1298
+ :param store: The active run context.
1299
+ :param coid: The row's client-order-id.
1300
+ :param filled_qty: Confirmed fill quantity from the snapshot.
1301
+ :param new_state: The state to land in (always :data:`STATE_CONFIRMED`
1302
+ for the current call sites; passed through for clarity).
1303
+ :param extras_patch: Plugin-supplied extras to merge into the row
1304
+ (e.g. ``{'kind': 'position', 'entry_filled_at': now_ts}``).
1305
+ Merged on top of the existing ``extras`` — the journal does not
1306
+ validate the keys.
1307
+ """
1308
+ fields: dict[str, Any] = {
1309
+ 'state': new_state,
1310
+ 'filled_qty': filled_qty,
1311
+ }
1312
+ if extras_patch:
1313
+ existing = store.get_order(coid)
1314
+ merged = dict(existing.extras or {}) if existing is not None else {}
1315
+ merged.update(extras_patch)
1316
+ fields['extras'] = merged
1317
+ store.upsert_order(coid, **fields)
1318
+
1319
+
1320
+ def mark_reconcile_terminal_close(
1321
+ store: 'RunContext',
1322
+ *,
1323
+ coid: str,
1324
+ new_state: str,
1325
+ extras_patch: Mapping[str, Any] | None,
1326
+ close_row: bool,
1327
+ ) -> None:
1328
+ """Terminate a row from a reconcile-path observation.
1329
+
1330
+ Used by :meth:`~pynecore.core.broker.journal.DispatchJournal.apply_reconcile_outcome`
1331
+ for every terminal close that originates in the reconciler:
1332
+ bracket sibling retire on mixed-bracket rejection, pending-trail
1333
+ sibling parent-rejection cascade, missing-pending grace expiry,
1334
+ unexpected-cancel cascade, and eager-teardown follow-up after a
1335
+ natural close.
1336
+
1337
+ The state mutation, the optional extras merge, and the
1338
+ :meth:`RunContext.close_order` (when ``close_row`` is ``True``)
1339
+ happen in this single helper; the journal then writes a separate
1340
+ audit event. The split mirrors :func:`mark_confirmed_with_fill`
1341
+ style — terminal facts persisted first, audit logging by the
1342
+ caller.
1343
+
1344
+ :param store: The active run context.
1345
+ :param coid: The row's client-order-id.
1346
+ :param new_state: Terminal state to land in — typically
1347
+ :data:`STATE_REJECTED` for bracket retire / cascade paths.
1348
+ ``close_row=True`` is what drops the row from the live set.
1349
+ :param extras_patch: Plugin-supplied extras to merge before the
1350
+ terminal transition (rarely used on this path; usually
1351
+ ``None``).
1352
+ :param close_row: When ``True``, also calls
1353
+ :meth:`RunContext.close_order` after the state update — this
1354
+ is the steady-state default for reconcile terminal closures.
1355
+ """
1356
+ fields: dict[str, Any] = {'state': new_state}
1357
+ if extras_patch:
1358
+ existing = store.get_order(coid)
1359
+ merged = dict(existing.extras or {}) if existing is not None else {}
1360
+ merged.update(extras_patch)
1361
+ fields['extras'] = merged
1362
+ store.upsert_order(coid, **fields)
1363
+ if close_row:
1364
+ store.close_order(coid)
1365
+
1366
+
1367
+ # === Engine-trigger partial bracket leg helpers ============================
1368
+
1369
+ def create_engine_trigger_partial_leg_row(
1370
+ store: 'RunContext',
1371
+ *,
1372
+ coid: str,
1373
+ symbol: str,
1374
+ side: str,
1375
+ qty: float,
1376
+ intent_key: str,
1377
+ pine_entry_id: str,
1378
+ from_entry: str,
1379
+ leg_kind: str,
1380
+ leg_state: str,
1381
+ parent_pine_entry_id: str,
1382
+ parent_entry_dispatch_ref: str,
1383
+ intent_partial_qty: float,
1384
+ trigger_level: float | None = None,
1385
+ trigger_offset: float | None = None,
1386
+ trail_activation_level: float | None = None,
1387
+ trail_activation_offset: float | None = None,
1388
+ oca_group: str | None = None,
1389
+ oca_type: str | None = None,
1390
+ extra_payload: Mapping[str, Any] | None = None,
1391
+ ) -> None:
1392
+ """Persist one engine-trigger partial bracket leg row.
1393
+
1394
+ The row carries no exchange-side order — the engine state machine
1395
+ in :class:`~pynecore.core.broker.software_partial_bracket_engine.SoftwarePartialBracketEngine`
1396
+ owns it. ``orders.state`` is set to :data:`STATE_PARTIAL_BRACKET_LEG`
1397
+ so the journal / reconcile paths short-circuit on these rows; the
1398
+ actual engine-trigger phase lives under
1399
+ :data:`EXTRAS_KEY_LEG_STATE` and is updated via
1400
+ :func:`update_engine_trigger_partial_leg_state`.
1401
+
1402
+ The function deliberately mirrors :func:`create_entry_order_row`'s
1403
+ "insert-only" shape — the row is freshly written by the dispatch
1404
+ path and never re-inserted by the same call site.
1405
+
1406
+ :param store: The active run context.
1407
+ :param coid: The leg's canonical client-order-id. The engine
1408
+ constructs this deterministically from ``(parent_dispatch_ref,
1409
+ leg_kind)`` so a restart-mid-dispatch never produces two
1410
+ rows for the same leg.
1411
+ :param symbol: Exchange-side symbol.
1412
+ :param side: ``'buy'`` / ``'sell'`` — the CLOSE side, opposite the
1413
+ parent position direction.
1414
+ :param qty: Partial-close quantity, already quantized to the
1415
+ broker's lot step.
1416
+ :param intent_key: The owning :class:`ExitIntent`'s diff key.
1417
+ :param pine_entry_id: Pine ``strategy.exit(id=...)`` value, kept
1418
+ for audit symmetry with non-leg rows.
1419
+ :param from_entry: Pine ``strategy.exit(from_entry=...)`` — the
1420
+ parent entry id the leg attaches to.
1421
+ :param leg_kind: One of :data:`LEG_KIND_TP_PARTIAL`,
1422
+ :data:`LEG_KIND_SL_PARTIAL`, :data:`LEG_KIND_TRAIL_PARTIAL`.
1423
+ :param leg_state: Initial state — either :data:`LEG_STATE_ARMED`
1424
+ (entry already filled, absolute trigger level known) or
1425
+ :data:`LEG_STATE_PENDING_ENTRY` (entry pending, tick offsets
1426
+ not yet resolved).
1427
+ :param parent_pine_entry_id: Same as ``from_entry`` — kept
1428
+ separately for clarity at recovery time.
1429
+ :param parent_entry_dispatch_ref: The parent entry row's
1430
+ ``client_order_id``. Used by reconciliation to guard against
1431
+ a stale leg attaching to a new parent that happens to share
1432
+ the same Pine entry id (the unique COID prevents that
1433
+ confusion).
1434
+ :param intent_partial_qty: Original :class:`ExitIntent.qty` —
1435
+ kept separately from ``qty`` (which is the dispatched close
1436
+ quantity) so the safety check can compare intent vs. live
1437
+ parent qty at trigger time.
1438
+ :param trigger_level: Absolute price the engine watches for to
1439
+ fire the leg. ``None`` while the leg is ``pending_entry``.
1440
+ :param trigger_offset: Trail offset (price units, not ticks) for
1441
+ :data:`LEG_KIND_TRAIL_PARTIAL`. ``None`` for tp / sl legs.
1442
+ :param trail_activation_level: Absolute activation price for a
1443
+ :data:`LEG_KIND_TRAIL_PARTIAL` leg. ``None`` for tp / sl legs
1444
+ or while the leg is ``pending_entry``.
1445
+ :param trail_activation_offset: Activation offset (price units) for
1446
+ a trail leg, resolved to an absolute level once the parent fill
1447
+ price is known. ``None`` for tp / sl legs.
1448
+ :param oca_group: OCA group name the cascade-cancel keys on.
1449
+ :param oca_type: OCA group type (``'cancel'`` for partial
1450
+ brackets — TP filling cancels the SL and vice versa).
1451
+ :param extra_payload: Additional plugin-specific extras to merge
1452
+ into the row's ``extras``. Useful for journal anchors.
1453
+ :raises ValueError: When ``leg_kind`` is not one of the three
1454
+ canonical values, or ``leg_state`` is not ``armed`` /
1455
+ ``pending_entry``.
1456
+ """
1457
+ if leg_kind not in ENGINE_TRIGGER_LEG_KINDS:
1458
+ raise ValueError(
1459
+ f"create_engine_trigger_partial_leg_row: leg_kind must be "
1460
+ f"one of {sorted(ENGINE_TRIGGER_LEG_KINDS)}, got {leg_kind!r}"
1461
+ )
1462
+ if leg_state not in (LEG_STATE_ARMED, LEG_STATE_PENDING_ENTRY):
1463
+ raise ValueError(
1464
+ f"create_engine_trigger_partial_leg_row: leg_state must be "
1465
+ f"{LEG_STATE_ARMED!r} or {LEG_STATE_PENDING_ENTRY!r}, "
1466
+ f"got {leg_state!r}"
1467
+ )
1468
+ extras: dict[str, Any] = {
1469
+ EXTRAS_KEY_LEG_KIND: leg_kind,
1470
+ EXTRAS_KEY_LEG_STATE: leg_state,
1471
+ EXTRAS_KEY_TRIGGER_LEVEL: trigger_level,
1472
+ EXTRAS_KEY_TRIGGER_OFFSET: trigger_offset,
1473
+ EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: trail_activation_level,
1474
+ EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET: trail_activation_offset,
1475
+ EXTRAS_KEY_PARENT_PINE_ENTRY_ID: parent_pine_entry_id,
1476
+ EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF: parent_entry_dispatch_ref,
1477
+ EXTRAS_KEY_INTENT_PARTIAL_QTY: intent_partial_qty,
1478
+ EXTRAS_KEY_OCA_GROUP: oca_group,
1479
+ EXTRAS_KEY_OCA_TYPE: oca_type,
1480
+ }
1481
+ if extra_payload:
1482
+ extras.update(extra_payload)
1483
+ # Same-bar modify/cancel→recreate path: the prior leg row was
1484
+ # closed by ``_dispatch_modify`` (which calls
1485
+ # :meth:`RunContext.close_order` on the legs via
1486
+ # :func:`update_engine_trigger_partial_leg_state` →
1487
+ # ``cancel_legs_for_intent``). The fresh dispatch re-uses the
1488
+ # same bar timestamp and retry sequence and therefore reaches
1489
+ # this helper with the SAME ``coid``. ``upsert_order`` does not
1490
+ # clear ``closed_ts_ms``, so without an explicit reopen the row
1491
+ # stays out of ``iter_live_orders`` / ``iter_active_engine_trigger_partial_legs``
1492
+ # after a restart and the bracket protection disappears. The
1493
+ # call is a no-op when the row does not yet exist or is already
1494
+ # live.
1495
+ store.reopen_order(coid)
1496
+ store.upsert_order(
1497
+ coid,
1498
+ symbol=symbol,
1499
+ side=side,
1500
+ qty=qty,
1501
+ state=STATE_PARTIAL_BRACKET_LEG,
1502
+ intent_key=intent_key,
1503
+ pine_entry_id=pine_entry_id,
1504
+ from_entry=from_entry,
1505
+ extras=extras,
1506
+ )
1507
+
1508
+
1509
+ def update_engine_trigger_partial_leg_state(
1510
+ store: 'RunContext',
1511
+ *,
1512
+ coid: str,
1513
+ new_leg_state: str,
1514
+ trigger_level: float | None = None,
1515
+ trigger_offset: float | None = None,
1516
+ qty: float | None = None,
1517
+ extras_patch: Mapping[str, Any] | None = None,
1518
+ close_row: bool | None = None,
1519
+ ) -> None:
1520
+ """Transition an engine-trigger partial bracket leg row.
1521
+
1522
+ Three categories of state move call this helper:
1523
+
1524
+ - ``pending_entry`` → ``armed`` after the parent entry fills (the
1525
+ caller resolves the tick offsets into absolute price levels and
1526
+ passes them via ``trigger_level``).
1527
+ - ``armed`` → ``triggering`` → ``triggered`` / ``triggered_failed``
1528
+ / ``triggered_unknown`` along the close-dispatch path.
1529
+ - ``armed`` → ``cascaded_cancel`` / ``aborted_*`` along the OCA
1530
+ cascade, parent-gone abort, and broker-native fail-safe paths.
1531
+
1532
+ The function MUST be called with ``close_row=True`` for any
1533
+ transition into :data:`LEG_STATE_TERMINAL` — the helper does NOT
1534
+ infer terminality on its own because some intermediate states
1535
+ (``triggered_failed``, ``triggered_unknown``) look terminal but
1536
+ are retried by the engine in place.
1537
+
1538
+ :param store: The active run context.
1539
+ :param coid: The leg row's client-order-id.
1540
+ :param new_leg_state: Target value for
1541
+ :data:`EXTRAS_KEY_LEG_STATE`. Must be one of the
1542
+ :data:`LEG_STATE_*` constants.
1543
+ :param trigger_level: When non-``None``, overwrites the leg's
1544
+ absolute trigger price (used both for the pending→armed
1545
+ promotion and for trail recompute moves).
1546
+ :param trigger_offset: When non-``None``, overwrites the leg's
1547
+ trail offset (rare; the engine's pricepath is to bump
1548
+ ``trigger_level`` instead).
1549
+ :param qty: When non-``None``, overwrites the leg row's ``qty``
1550
+ column. The WATCH-phase safety check may cap the close
1551
+ quantity below the originally-recorded ``intent_partial_qty``
1552
+ when the parent has since been partially reduced; persisting
1553
+ the capped value here keeps ``restart_replay`` from rebuilding
1554
+ the leg with the stale, larger size.
1555
+ :param extras_patch: Additional ``extras`` keys to merge before
1556
+ the upsert — typically empty, used by audit-rich call sites
1557
+ (e.g. failure-reason capture on ``triggered_failed``).
1558
+ :param close_row: When ``True``, also calls
1559
+ :meth:`RunContext.close_order` after the state update. The
1560
+ engine sets this on every transition into
1561
+ :data:`LEG_STATE_TERMINAL`.
1562
+ :raises ValueError: When ``new_leg_state`` is not one of the
1563
+ canonical :data:`LEG_STATE_*` values.
1564
+ """
1565
+ if new_leg_state not in (LEG_STATE_LIVE | LEG_STATE_TERMINAL):
1566
+ raise ValueError(
1567
+ f"update_engine_trigger_partial_leg_state: new_leg_state must be "
1568
+ f"one of {sorted(LEG_STATE_LIVE | LEG_STATE_TERMINAL)}, "
1569
+ f"got {new_leg_state!r}"
1570
+ )
1571
+ existing = store.get_order(coid)
1572
+ merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
1573
+ merged[EXTRAS_KEY_LEG_STATE] = new_leg_state
1574
+ if trigger_level is not None:
1575
+ merged[EXTRAS_KEY_TRIGGER_LEVEL] = trigger_level
1576
+ if trigger_offset is not None:
1577
+ merged[EXTRAS_KEY_TRIGGER_OFFSET] = trigger_offset
1578
+ if extras_patch:
1579
+ merged.update(extras_patch)
1580
+ upsert_fields: dict[str, Any] = {'extras': merged}
1581
+ if qty is not None:
1582
+ upsert_fields['qty'] = qty
1583
+ store.upsert_order(coid, **upsert_fields)
1584
+ if close_row:
1585
+ store.close_order(coid)
1586
+
1587
+
1588
+ def iter_active_engine_trigger_partial_legs(
1589
+ store: 'RunContext',
1590
+ *,
1591
+ symbol: str | None = None,
1592
+ from_entry: str | None = None,
1593
+ ) -> Iterator['OrderRow']:
1594
+ """Iterate live engine-trigger partial bracket leg rows.
1595
+
1596
+ Used by :meth:`SoftwarePartialBracketEngine.restart_replay` to
1597
+ rebuild the in-memory leg ledger after a runner restart, and by
1598
+ the cascade-cancel paths that need every leg attached to one
1599
+ parent. Returns rows whose ``orders.state`` is
1600
+ :data:`STATE_PARTIAL_BRACKET_LEG`, are not closed
1601
+ (``closed_ts_ms IS NULL``), and whose
1602
+ :data:`EXTRAS_KEY_LEG_STATE` is one of :data:`LEG_STATE_LIVE`
1603
+ (terminal rows are filtered out even when ``close_order`` has
1604
+ not yet flipped ``closed_ts_ms``). :data:`LEG_STATE_LIVE` includes
1605
+ both engine-owned active states and the cancel-tentative
1606
+ verification state.
1607
+
1608
+ :param store: The active run context.
1609
+ :param symbol: Optional symbol filter — passed straight through
1610
+ to :meth:`RunContext.iter_live_orders`.
1611
+ :param from_entry: Optional Pine parent entry id filter — same
1612
+ passthrough; the cascade-cancel callers use this to target
1613
+ all legs of one parent.
1614
+ """
1615
+ for row in store.iter_live_orders(symbol=symbol, from_entry=from_entry):
1616
+ if row.state != STATE_PARTIAL_BRACKET_LEG:
1617
+ continue
1618
+ leg_state = (row.extras or {}).get(EXTRAS_KEY_LEG_STATE)
1619
+ if leg_state not in LEG_STATE_LIVE:
1620
+ continue
1621
+ yield row
1622
+
1623
+
1624
+ # === One-way emulation close-leg helpers ===================================
1625
+
1626
+ def create_close_leg_row(
1627
+ store: 'RunContext',
1628
+ *,
1629
+ coid: str,
1630
+ symbol: str,
1631
+ side: str,
1632
+ qty: float,
1633
+ intent_key: str,
1634
+ pine_entry_id: str,
1635
+ parent_close_coid: str,
1636
+ leg_id: str,
1637
+ leg_volume: int,
1638
+ extra_payload: Mapping[str, Any] | None = None,
1639
+ ) -> None:
1640
+ """Persist one one-way emulation close-leg row (PERSIST-FIRST).
1641
+
1642
+ Written by :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator`
1643
+ BEFORE it dispatches the leg's close, so a crash mid-fan-out leaves a durable
1644
+ record of which legs were owed. The row carries no exchange order — its
1645
+ ``orders.state`` is :data:`STATE_CLOSE_LEG` so the journal / reconcile paths
1646
+ short-circuit on it; the leg's close FILL arrives on the normal order-event
1647
+ stream and is applied FIFO by ``record_fill`` as a ``LegType.CLOSE``.
1648
+
1649
+ The ``coid`` is the parent CloseIntent's coid suffixed with the broker leg id
1650
+ (``f"{parent_close_coid}:{leg_id}"``), so a restart that re-runs the same
1651
+ dispatch upserts the SAME row rather than duplicating it.
1652
+
1653
+ :param store: The active run context.
1654
+ :param coid: The leg row's deterministic client-order-id.
1655
+ :param symbol: Exchange-side symbol.
1656
+ :param side: The close side (opposite the position direction being reduced).
1657
+ :param qty: The Pine-unit quantity this leg closes (its FIFO slice).
1658
+ :param intent_key: The owning :class:`CloseIntent`'s diff key.
1659
+ :param pine_entry_id: The owning ``strategy.close(id=...)`` value, for audit.
1660
+ :param parent_close_coid: The CloseIntent dispatch's ``KIND_CLOSE`` coid.
1661
+ :param leg_id: Broker leg id (cTrader ``positionId``) this row closes.
1662
+ :param leg_volume: Broker-grid integer volume dispatched for the leg.
1663
+ :param extra_payload: Additional plugin-specific extras to merge.
1664
+ """
1665
+ extras: dict[str, Any] = {
1666
+ EXTRAS_KEY_CLOSE_LEG_STATE: CLOSE_LEG_STATE_PENDING,
1667
+ EXTRAS_KEY_CLOSE_LEG_ID: leg_id,
1668
+ EXTRAS_KEY_CLOSE_PARENT_COID: parent_close_coid,
1669
+ EXTRAS_KEY_CLOSE_LEG_VOLUME: leg_volume,
1670
+ }
1671
+ if extra_payload:
1672
+ extras.update(extra_payload)
1673
+ # A restart-mid-fan re-dispatch reaches this helper with the SAME derived
1674
+ # coid; reopen so the re-persisted row rejoins the live set (upsert_order
1675
+ # does not clear ``closed_ts_ms`` on its own). No-op when the row is absent
1676
+ # or already live.
1677
+ store.reopen_order(coid)
1678
+ store.upsert_order(
1679
+ coid,
1680
+ symbol=symbol,
1681
+ side=side,
1682
+ qty=qty,
1683
+ state=STATE_CLOSE_LEG,
1684
+ intent_key=intent_key,
1685
+ pine_entry_id=pine_entry_id,
1686
+ extras=extras,
1687
+ )
1688
+
1689
+
1690
+ def update_close_leg_state(
1691
+ store: 'RunContext',
1692
+ *,
1693
+ coid: str,
1694
+ new_state: str,
1695
+ close_row: bool = False,
1696
+ extras_patch: Mapping[str, Any] | None = None,
1697
+ ) -> None:
1698
+ """Transition a close-leg row's :data:`EXTRAS_KEY_CLOSE_LEG_STATE`.
1699
+
1700
+ Set ``close_row=True`` for the terminal :data:`CLOSE_LEG_STATE_DISPATCHED`
1701
+ transition (the per-leg close acked), which also calls
1702
+ :meth:`RunContext.close_order` so the row leaves
1703
+ :func:`iter_active_close_legs`'s live set.
1704
+
1705
+ :param store: The active run context.
1706
+ :param coid: The leg row's client-order-id.
1707
+ :param new_state: Target :data:`CLOSE_LEG_STATE_PENDING` /
1708
+ :data:`CLOSE_LEG_STATE_DISPATCHED`.
1709
+ :param close_row: When ``True``, also close the row (terminal).
1710
+ :param extras_patch: Additional extras to merge before the upsert.
1711
+ :raises ValueError: When ``new_state`` is not a canonical close-leg state.
1712
+ """
1713
+ if new_state not in (CLOSE_LEG_STATE_PENDING, CLOSE_LEG_STATE_DISPATCHED):
1714
+ raise ValueError(
1715
+ f"update_close_leg_state: new_state must be one of "
1716
+ f"{{CLOSE_LEG_STATE_PENDING, CLOSE_LEG_STATE_DISPATCHED}}, "
1717
+ f"got {new_state!r}"
1718
+ )
1719
+ existing = store.get_order(coid)
1720
+ merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
1721
+ merged[EXTRAS_KEY_CLOSE_LEG_STATE] = new_state
1722
+ if extras_patch:
1723
+ merged.update(extras_patch)
1724
+ store.upsert_order(coid, extras=merged)
1725
+ if close_row:
1726
+ store.close_order(coid)
1727
+
1728
+
1729
+ def iter_active_close_legs(
1730
+ store: 'RunContext',
1731
+ *,
1732
+ symbol: str | None = None,
1733
+ ) -> Iterator['OrderRow']:
1734
+ """Iterate live (pending) close-leg rows for restart replay.
1735
+
1736
+ Returns rows whose ``orders.state`` is :data:`STATE_CLOSE_LEG`, are not
1737
+ closed, and whose :data:`EXTRAS_KEY_CLOSE_LEG_STATE` is in
1738
+ :data:`CLOSE_LEG_STATE_LIVE` — a fan-out leg whose close was persisted but
1739
+ never acked. :meth:`OneWayEmulator.restart_replay` reconciles each against
1740
+ the live broker legs before re-dispatching.
1741
+
1742
+ :param store: The active run context.
1743
+ :param symbol: Optional symbol filter, passed to
1744
+ :meth:`RunContext.iter_live_orders`.
1745
+ """
1746
+ for row in store.iter_live_orders(symbol=symbol):
1747
+ if row.state != STATE_CLOSE_LEG:
1748
+ continue
1749
+ leg_state = (row.extras or {}).get(EXTRAS_KEY_CLOSE_LEG_STATE)
1750
+ if leg_state not in CLOSE_LEG_STATE_LIVE:
1751
+ continue
1752
+ yield row
1753
+
1754
+
1755
+ # === One-way emulation residual-open helpers ===============================
1756
+
1757
+ def create_residual_open_row(
1758
+ store: 'RunContext',
1759
+ *,
1760
+ coid: str,
1761
+ symbol: str,
1762
+ side: str,
1763
+ qty: float,
1764
+ intent_key: str,
1765
+ pine_entry_id: str,
1766
+ entry_coid: str,
1767
+ run_tag: str,
1768
+ bar_ts_ms: int,
1769
+ retry_seq: int,
1770
+ ) -> None:
1771
+ """Persist a reversal's residual-OPEN breadcrumb (PERSIST-FIRST).
1772
+
1773
+ Written by :meth:`~pynecore.core.broker.one_way_emulator.OneWayEmulator.run_reversal`
1774
+ BEFORE the FIFO closes, so a crash in the window between the closes landing
1775
+ and the residual :meth:`PositionPort.place_leg` persisting its own entry row
1776
+ leaves a durable record that an open is owed. The row owns no exchange order —
1777
+ its ``orders.state`` is :data:`STATE_RESIDUAL_OPEN`, so the journal /
1778
+ reconcile / startup-recovery paths short-circuit on it.
1779
+ :meth:`OneWayEmulator.restart_replay` reconciles it against the residual entry
1780
+ row (keyed on ``entry_coid``) and re-dispatches the open only when that row
1781
+ never landed, then clears this breadcrumb.
1782
+
1783
+ The ``coid`` is the reversal's ``KIND_CLOSE`` parent coid suffixed
1784
+ ``":residual"``, so a restart re-run upserts the SAME row.
1785
+
1786
+ :param store: The active run context.
1787
+ :param coid: The breadcrumb row's deterministic client-order-id.
1788
+ :param symbol: Exchange-side symbol.
1789
+ :param side: The residual open's side (the entry's own direction).
1790
+ :param qty: The residual open quantity (Pine units).
1791
+ :param intent_key: The owning :class:`EntryIntent`'s diff key.
1792
+ :param pine_entry_id: The owning ``strategy.entry(id=...)`` value.
1793
+ :param entry_coid: The residual ``place_leg``'s deterministic entry coid
1794
+ (``KIND_ENTRY``, or ``KIND_ENTRY_STOP`` for a stop-fired reversal) —
1795
+ its presence on replay proves the open already persisted.
1796
+ :param run_tag: Dispatch envelope run tag (to rebuild the envelope on replay).
1797
+ :param bar_ts_ms: Dispatch envelope bar timestamp.
1798
+ :param retry_seq: Dispatch envelope retry sequence.
1799
+ """
1800
+ extras: dict[str, Any] = {
1801
+ EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID: entry_coid,
1802
+ EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG: run_tag,
1803
+ EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS: bar_ts_ms,
1804
+ EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ: retry_seq,
1805
+ }
1806
+ store.reopen_order(coid)
1807
+ store.upsert_order(
1808
+ coid,
1809
+ symbol=symbol,
1810
+ side=side,
1811
+ qty=qty,
1812
+ state=STATE_RESIDUAL_OPEN,
1813
+ intent_key=intent_key,
1814
+ pine_entry_id=pine_entry_id,
1815
+ extras=extras,
1816
+ )
1817
+
1818
+
1819
+ def clear_residual_open_row(store: 'RunContext', coid: str) -> None:
1820
+ """Finalise a residual-open breadcrumb once the open is durable / replayed."""
1821
+ store.close_order(coid)
1822
+
1823
+
1824
+ def iter_active_residual_opens(
1825
+ store: 'RunContext',
1826
+ *,
1827
+ symbol: str | None = None,
1828
+ ) -> Iterator['OrderRow']:
1829
+ """Iterate live residual-open breadcrumbs for restart replay.
1830
+
1831
+ Returns rows whose ``orders.state`` is :data:`STATE_RESIDUAL_OPEN` and which
1832
+ are not yet closed — a reversal whose residual open was recorded but whose
1833
+ durability (its own entry row) the crash may have pre-empted.
1834
+
1835
+ :param store: The active run context.
1836
+ :param symbol: Optional symbol filter, passed to
1837
+ :meth:`RunContext.iter_live_orders`.
1838
+ """
1839
+ for row in store.iter_live_orders(symbol=symbol):
1840
+ if row.state == STATE_RESIDUAL_OPEN:
1841
+ yield row
1842
+
1843
+
1844
+ # === One-way emulation bracket-ownership helpers ===========================
1845
+
1846
+ def create_bracket_ownership_row(
1847
+ store: 'RunContext',
1848
+ *,
1849
+ coid: str,
1850
+ symbol: str,
1851
+ side: str,
1852
+ qty: float,
1853
+ intent_key: str,
1854
+ pine_entry_id: str,
1855
+ from_entry: str,
1856
+ leg_id: str,
1857
+ attach_coid: str,
1858
+ tp_price: float | None,
1859
+ sl_price: float | None,
1860
+ trail_price: float | None,
1861
+ trail_offset: float | None,
1862
+ oca_name: str | None = None,
1863
+ oca_type: str | None = None,
1864
+ extra_payload: Mapping[str, Any] | None = None,
1865
+ ) -> None:
1866
+ """Persist one per-leg bracket-ownership row (PERSIST-FIRST).
1867
+
1868
+ Written by :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator`
1869
+ BEFORE it amends an exit's TP/SL/trailing bracket onto one hedging leg, so a
1870
+ crash mid-replication leaves a durable record of exactly which legs this exit
1871
+ protects. The row carries no exchange order — its ``orders.state`` is
1872
+ :data:`STATE_BRACKET_OWN` so the journal / reconcile paths short-circuit; the
1873
+ leg's protective FILL (if it fires) arrives on the normal order-event stream.
1874
+
1875
+ The ``coid`` is the exit's bracket-attach coid suffixed with the broker leg
1876
+ id (``f"{attach_coid}:{leg_id}"``), so a re-attach upserts the SAME row and
1877
+ two different exits (distinct attach coids) own disjoint coid namespaces. The
1878
+ ``from_entry`` is written to its own column so the clear path can pre-filter
1879
+ the live set cheaply via :meth:`RunContext.iter_live_orders`.
1880
+
1881
+ :param store: The active run context.
1882
+ :param coid: The ownership row's deterministic client-order-id.
1883
+ :param symbol: Exchange-side symbol.
1884
+ :param side: The exit (bracket) side.
1885
+ :param qty: The owning :class:`ExitIntent`'s quantity, for audit.
1886
+ :param intent_key: The owning :class:`ExitIntent`'s diff key
1887
+ (``f"{pine_id}\\0{from_entry}"``), matched by the clear path.
1888
+ :param pine_entry_id: The owning ``strategy.exit(id=...)`` value.
1889
+ :param from_entry: The exit's parent entry id (its own column for filtering).
1890
+ :param leg_id: Broker leg id (cTrader ``positionId``) the bracket is on.
1891
+ :param attach_coid: The exit's ``KIND_EXIT_SL`` bracket-attach coid.
1892
+ :param tp_price: Pine-unit take-profit level last replicated (or ``None``).
1893
+ :param sl_price: Pine-unit stop-loss level last replicated (or ``None``).
1894
+ :param trail_price: Pine-unit trailing-stop activation price (or ``None``).
1895
+ :param trail_offset: Pine-unit trailing offset last replicated (or ``None``).
1896
+ :param oca_name: The owning exit's OCA group name (or ``None``).
1897
+ :param oca_type: The owning exit's OCA type string (``"reduce"`` /
1898
+ ``"cancel"`` / ``"none"``, or ``None``).
1899
+ :param extra_payload: Additional plugin-specific extras to merge.
1900
+ """
1901
+ extras: dict[str, Any] = {
1902
+ EXTRAS_KEY_BRACKET_OWN_STATE: BRACKET_OWN_STATE_ACTIVE,
1903
+ EXTRAS_KEY_BRACKET_OWN_LEG_ID: leg_id,
1904
+ EXTRAS_KEY_BRACKET_OWN_ATTACH_COID: attach_coid,
1905
+ EXTRAS_KEY_BRACKET_OWN_TP: tp_price,
1906
+ EXTRAS_KEY_BRACKET_OWN_SL: sl_price,
1907
+ EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE: trail_price,
1908
+ EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET: trail_offset,
1909
+ EXTRAS_KEY_BRACKET_OWN_OCA_NAME: oca_name,
1910
+ EXTRAS_KEY_BRACKET_OWN_OCA_TYPE: oca_type,
1911
+ }
1912
+ if extra_payload:
1913
+ extras.update(extra_payload)
1914
+ # A re-attach (modify, or restart re-assert) reaches this helper with the
1915
+ # SAME derived coid; reopen so the re-persisted row rejoins the live set
1916
+ # (upsert_order does not clear ``closed_ts_ms`` on its own). No-op when the
1917
+ # row is absent or already live.
1918
+ store.reopen_order(coid)
1919
+ store.upsert_order(
1920
+ coid,
1921
+ symbol=symbol,
1922
+ side=side,
1923
+ qty=qty,
1924
+ state=STATE_BRACKET_OWN,
1925
+ intent_key=intent_key,
1926
+ pine_entry_id=pine_entry_id,
1927
+ from_entry=from_entry,
1928
+ extras=extras,
1929
+ )
1930
+
1931
+
1932
+ def update_bracket_ownership_state(
1933
+ store: 'RunContext',
1934
+ *,
1935
+ coid: str,
1936
+ new_state: str,
1937
+ close_row: bool = False,
1938
+ extras_patch: Mapping[str, Any] | None = None,
1939
+ ) -> None:
1940
+ """Transition a bracket-ownership row's :data:`EXTRAS_KEY_BRACKET_OWN_STATE`.
1941
+
1942
+ Set ``close_row=True`` for the terminal :data:`BRACKET_OWN_STATE_RELEASED`
1943
+ transition (the bracket cleared from this leg), which also calls
1944
+ :meth:`RunContext.close_order` so the row leaves
1945
+ :func:`iter_active_bracket_ownerships`'s live set.
1946
+
1947
+ :param store: The active run context.
1948
+ :param coid: The ownership row's client-order-id.
1949
+ :param new_state: Target :data:`BRACKET_OWN_STATE_ACTIVE` /
1950
+ :data:`BRACKET_OWN_STATE_CLEARING` / :data:`BRACKET_OWN_STATE_RELEASED`.
1951
+ :param close_row: When ``True``, also close the row (terminal).
1952
+ :param extras_patch: Additional extras to merge before the upsert.
1953
+ :raises ValueError: When ``new_state`` is not a canonical ownership state.
1954
+ """
1955
+ if new_state not in (
1956
+ BRACKET_OWN_STATE_ACTIVE,
1957
+ BRACKET_OWN_STATE_CLEARING,
1958
+ BRACKET_OWN_STATE_RELEASED,
1959
+ ):
1960
+ raise ValueError(
1961
+ f"update_bracket_ownership_state: new_state must be one of "
1962
+ f"{{BRACKET_OWN_STATE_ACTIVE, BRACKET_OWN_STATE_CLEARING, "
1963
+ f"BRACKET_OWN_STATE_RELEASED}}, got {new_state!r}"
1964
+ )
1965
+ existing = store.get_order(coid)
1966
+ merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
1967
+ merged[EXTRAS_KEY_BRACKET_OWN_STATE] = new_state
1968
+ if extras_patch:
1969
+ merged.update(extras_patch)
1970
+ store.upsert_order(coid, extras=merged)
1971
+ if close_row:
1972
+ store.close_order(coid)
1973
+
1974
+
1975
+ def iter_active_bracket_ownerships(
1976
+ store: 'RunContext',
1977
+ *,
1978
+ symbol: str | None = None,
1979
+ from_entry: str | None = None,
1980
+ ) -> Iterator['OrderRow']:
1981
+ """Iterate live (active) bracket-ownership rows.
1982
+
1983
+ Returns rows whose ``orders.state`` is :data:`STATE_BRACKET_OWN`, are not
1984
+ closed, and whose :data:`EXTRAS_KEY_BRACKET_OWN_STATE` is in
1985
+ :data:`BRACKET_OWN_STATE_LIVE`. The clear path filters further by
1986
+ ``intent_key`` to touch only the legs a specific exit owns; the restart pass
1987
+ re-asserts every active row against the live broker legs.
1988
+
1989
+ :param store: The active run context.
1990
+ :param symbol: Optional symbol filter, passed to
1991
+ :meth:`RunContext.iter_live_orders`.
1992
+ :param from_entry: Optional parent-entry filter, passed to
1993
+ :meth:`RunContext.iter_live_orders` (the indexed ``from_entry`` column).
1994
+ """
1995
+ for row in store.iter_live_orders(symbol=symbol, from_entry=from_entry):
1996
+ if row.state != STATE_BRACKET_OWN:
1997
+ continue
1998
+ own_state = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_STATE)
1999
+ if own_state not in BRACKET_OWN_STATE_LIVE:
2000
+ continue
2001
+ yield row
2002
+
2003
+
2004
+ # === Entry-stop watch helpers ==============================================
2005
+
2006
+ def create_entry_stop_watch_row(
2007
+ store: 'RunContext',
2008
+ *,
2009
+ coid: str,
2010
+ symbol: str,
2011
+ side: str,
2012
+ qty: float,
2013
+ intent_key: str,
2014
+ pine_entry_id: str,
2015
+ stop_level: float,
2016
+ limit_coid: str,
2017
+ entry_stop_state: str = ENTRY_STOP_STATE_ARMED,
2018
+ extra_payload: Mapping[str, Any] | None = None,
2019
+ ) -> None:
2020
+ """Persist one entry-stop watch row (PERSIST-FIRST).
2021
+
2022
+ The row carries no exchange-side order — the
2023
+ :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
2024
+ state machine owns it. ``orders.state`` is set to
2025
+ :data:`STATE_ENTRY_STOP_WATCH` so the journal / reconcile paths
2026
+ short-circuit on these rows; the watch phase lives under
2027
+ :data:`EXTRAS_KEY_ENTRY_STOP_STATE` and is advanced via
2028
+ :func:`update_entry_stop_watch_state`.
2029
+
2030
+ :param store: The active run context.
2031
+ :param coid: The watch row's deterministic client-order-id — built from
2032
+ the parent entry envelope with
2033
+ :data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP_WATCH`, so a
2034
+ restart-mid-dispatch never produces two rows for one watch. Distinct
2035
+ from the stop-fired MARKET order's id
2036
+ (:data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP`).
2037
+ :param symbol: Exchange-side symbol.
2038
+ :param side: The ENTRY side (``'buy'`` / ``'sell'``) — the direction the
2039
+ stop-fired MARKET opens in, same as the native LIMIT leg.
2040
+ :param qty: Entry quantity, already quantized to the broker's lot step.
2041
+ :param intent_key: The owning :class:`EntryIntent`'s diff key (≡ pine_id).
2042
+ :param pine_entry_id: The Pine ``strategy.entry(id=...)`` value.
2043
+ :param stop_level: Absolute price the watch fires the MARKET at.
2044
+ :param limit_coid: The native LIMIT leg's client-order-id — the
2045
+ leg-scoped cancel target when the stop crosses.
2046
+ :param entry_stop_state: Initial state, normally
2047
+ :data:`ENTRY_STOP_STATE_ARMED`.
2048
+ :param extra_payload: Additional extras to merge into the row.
2049
+ :raises ValueError: When ``entry_stop_state`` is not a live state.
2050
+ """
2051
+ if entry_stop_state not in ENTRY_STOP_STATE_LIVE:
2052
+ raise ValueError(
2053
+ f"create_entry_stop_watch_row: entry_stop_state must be one of "
2054
+ f"{sorted(ENTRY_STOP_STATE_LIVE)}, got {entry_stop_state!r}"
2055
+ )
2056
+ extras: dict[str, Any] = {
2057
+ EXTRAS_KEY_ENTRY_STOP_STATE: entry_stop_state,
2058
+ EXTRAS_KEY_ENTRY_STOP_LEVEL: stop_level,
2059
+ EXTRAS_KEY_ENTRY_STOP_LIMIT_COID: limit_coid,
2060
+ EXTRAS_KEY_ENTRY_STOP_MARKET_COID: None,
2061
+ }
2062
+ if extra_payload:
2063
+ extras.update(extra_payload)
2064
+ store.reopen_order(coid)
2065
+ store.upsert_order(
2066
+ coid,
2067
+ symbol=symbol,
2068
+ side=side,
2069
+ qty=qty,
2070
+ state=STATE_ENTRY_STOP_WATCH,
2071
+ intent_key=intent_key,
2072
+ pine_entry_id=pine_entry_id,
2073
+ extras=extras,
2074
+ )
2075
+
2076
+
2077
+ def update_entry_stop_watch_state(
2078
+ store: 'RunContext',
2079
+ *,
2080
+ coid: str,
2081
+ new_state: str,
2082
+ market_coid: str | None = None,
2083
+ extras_patch: Mapping[str, Any] | None = None,
2084
+ close_row: bool | None = None,
2085
+ stop_level: float | None = None,
2086
+ qty: float | None = None,
2087
+ side: str | None = None,
2088
+ ) -> None:
2089
+ """Transition an entry-stop watch row.
2090
+
2091
+ :param store: The active run context.
2092
+ :param coid: The watch row's client-order-id.
2093
+ :param new_state: Target :data:`EXTRAS_KEY_ENTRY_STOP_STATE` value.
2094
+ :param market_coid: When non-``None``, persists the stop-fired MARKET
2095
+ order's deterministic client-order-id under
2096
+ :data:`EXTRAS_KEY_ENTRY_STOP_MARKET_COID`. Written on the transition
2097
+ into :data:`ENTRY_STOP_STATE_MARKET_PENDING`, BEFORE the POST, so a
2098
+ restart can verify-before-resend.
2099
+ :param extras_patch: Additional extras keys to merge before the upsert.
2100
+ :param close_row: When ``True``, also calls
2101
+ :meth:`RunContext.close_order` — set on every transition into
2102
+ :data:`ENTRY_STOP_STATE_TERMINAL`.
2103
+ :param stop_level: When non-``None``, overwrites the watch's fire level
2104
+ under :data:`EXTRAS_KEY_ENTRY_STOP_LEVEL`. Used by the modify path to
2105
+ re-sync the software STOP leg to an amended both-set entry.
2106
+ :param qty: When non-``None``, overwrites the watch row's ``qty`` column
2107
+ (the stop-fired MARKET size). Same modify-path use as ``stop_level``.
2108
+ :param side: When non-``None``, overwrites the watch row's ``side`` column
2109
+ (the entry direction). Same modify-path use as ``stop_level``.
2110
+ :raises ValueError: When ``new_state`` is not a canonical value.
2111
+ """
2112
+ if new_state not in (ENTRY_STOP_STATE_LIVE | ENTRY_STOP_STATE_TERMINAL):
2113
+ raise ValueError(
2114
+ f"update_entry_stop_watch_state: new_state must be one of "
2115
+ f"{sorted(ENTRY_STOP_STATE_LIVE | ENTRY_STOP_STATE_TERMINAL)}, "
2116
+ f"got {new_state!r}"
2117
+ )
2118
+ existing = store.get_order(coid)
2119
+ merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
2120
+ merged[EXTRAS_KEY_ENTRY_STOP_STATE] = new_state
2121
+ if market_coid is not None:
2122
+ merged[EXTRAS_KEY_ENTRY_STOP_MARKET_COID] = market_coid
2123
+ if stop_level is not None:
2124
+ merged[EXTRAS_KEY_ENTRY_STOP_LEVEL] = stop_level
2125
+ if extras_patch:
2126
+ merged.update(extras_patch)
2127
+ upsert_fields: dict[str, Any] = {'extras': merged}
2128
+ if qty is not None:
2129
+ upsert_fields['qty'] = qty
2130
+ if side is not None:
2131
+ upsert_fields['side'] = side
2132
+ store.upsert_order(coid, **upsert_fields)
2133
+ if close_row:
2134
+ store.close_order(coid)
2135
+
2136
+
2137
+ def iter_active_entry_stop_watches(
2138
+ store: 'RunContext',
2139
+ *,
2140
+ symbol: str | None = None,
2141
+ ) -> Iterator['OrderRow']:
2142
+ """Iterate live entry-stop watch rows.
2143
+
2144
+ Used by :meth:`SoftwareEntryStopEngine.restart_replay` to rebuild the
2145
+ in-memory watch ledger after a runner restart. Returns rows whose
2146
+ ``orders.state`` is :data:`STATE_ENTRY_STOP_WATCH`, are not closed, and
2147
+ whose :data:`EXTRAS_KEY_ENTRY_STOP_STATE` is one of
2148
+ :data:`ENTRY_STOP_STATE_LIVE` (terminal rows are filtered out even when
2149
+ ``close_order`` has not yet flipped ``closed_ts_ms``).
2150
+
2151
+ :param store: The active run context.
2152
+ :param symbol: Optional symbol filter — passed straight through to
2153
+ :meth:`RunContext.iter_live_orders`.
2154
+ """
2155
+ for row in store.iter_live_orders(symbol=symbol):
2156
+ if row.state != STATE_ENTRY_STOP_WATCH:
2157
+ continue
2158
+ watch_state = (row.extras or {}).get(EXTRAS_KEY_ENTRY_STOP_STATE)
2159
+ if watch_state not in ENTRY_STOP_STATE_LIVE:
2160
+ continue
2161
+ yield row