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,1600 @@
1
+ """
2
+ Data models for the broker plugin system.
3
+
4
+ These classes form the protocol between Pine Script, the Order Sync Engine,
5
+ and a concrete :class:`~pynecore.core.plugin.broker.BrokerPlugin`. Intent
6
+ objects describe what the script wants; Event objects describe what the
7
+ exchange actually did; Exchange* objects are snapshots of exchange state.
8
+
9
+ See ``docs/pynecore/plugin-system/broker-plugin-plan.md`` for the full design.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import StrEnum
15
+ from typing import TYPE_CHECKING
16
+
17
+ from pynecore.core.broker.idempotency import (
18
+ CLIENT_ORDER_ID_MAX_LEN,
19
+ build_client_order_id,
20
+ encode_wire_client_order_id,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from pynecore.core.broker.exceptions import BracketAttachAfterFillRejectedError
25
+
26
+ __all__ = [
27
+ 'OrderStatus',
28
+ 'OrderType',
29
+ 'LegType',
30
+ 'OcaType',
31
+ 'OcaPartialFillPolicy',
32
+ 'CapabilityLevel',
33
+ 'ExchangeOrder',
34
+ 'OrderEvent',
35
+ 'ExchangePosition',
36
+ 'PositionLeg',
37
+ 'ExchangeCapabilities',
38
+ 'EntryIntent',
39
+ 'ExitIntent',
40
+ 'CloseIntent',
41
+ 'CancelIntent',
42
+ 'BracketAttachRejectContext',
43
+ 'PendingDefensiveClose',
44
+ 'DispatchEnvelope',
45
+ 'ScriptRequirements',
46
+ 'InterceptorResult',
47
+ 'BrokerEvent',
48
+ 'AuthenticationFailedEvent',
49
+ 'BracketRegisteredEvent',
50
+ 'LegPartialRepairedEvent',
51
+ 'LegRepairFailedEvent',
52
+ 'BracketReconstructedEvent',
53
+ 'ManualInterventionRequiredEvent',
54
+ 'QuarantineEnteredEvent',
55
+ 'ProtectionDegradedEvent',
56
+ 'NativeFailsafeStateTransitionEvent',
57
+ 'PartialBracketBlockedDegradedFailsafeEvent',
58
+ 'EntryBlockedDegradedFailsafeEvent',
59
+ 'EntrySkippedDueToDegradedFailsafeEvent',
60
+ 'BrokerNativeFailsafeUnavailableEvent',
61
+ 'BrokerNativeFailsafeExternalEditEvent',
62
+ 'BrokerNativeFailsafeFullCloseEvent',
63
+ 'CancelDispositionOutcome',
64
+ 'PartialBracketCancelTentativeStartedEvent',
65
+ 'PartialBracketCancelTentativeResolvedEvent',
66
+ 'PartialBracketCancelTentativeDegradedEvent',
67
+ 'EntryDeferredCancelDispositionPendingEvent',
68
+ 'INTENT_KEY_SEP',
69
+ 'format_intent_key',
70
+ ]
71
+
72
+
73
+ # Field separator inside a compound intent_key (``pine_id<SEP>from_entry`` for
74
+ # ExitIntent / CancelIntent). NUL can never appear in a Pine identifier, so the
75
+ # split back to (pine_id, from_entry) is unambiguous — but it renders as an ugly
76
+ # ``\x00`` in logs, so user-facing messages route the key through
77
+ # :func:`format_intent_key`.
78
+ INTENT_KEY_SEP = "\0"
79
+ # Readable separator substituted for :data:`INTENT_KEY_SEP` in log output.
80
+ _INTENT_KEY_DISPLAY_SEP = "|"
81
+
82
+
83
+ def format_intent_key(key: str | None) -> str | None:
84
+ """Render an ``intent_key`` for a human-readable log line.
85
+
86
+ A compound exit / cancel key is ``pine_id<NUL>from_entry``; the raw NUL
87
+ prints as ``\\x00`` and clutters operator logs. Swap it for a readable
88
+ separator. Keys without the separator (entry / close intents, plain pine
89
+ ids) pass through unchanged, and ``None`` passes through too, so this is
90
+ safe to apply to any logged key — including the optional keys carried by
91
+ error/context objects.
92
+
93
+ :param key: The ``intent_key`` (or any id) about to be logged, or ``None``.
94
+ :return: The key with :data:`INTENT_KEY_SEP` replaced by a readable glyph,
95
+ or ``None`` unchanged.
96
+ """
97
+ if key is None:
98
+ return None
99
+ return key.replace(INTENT_KEY_SEP, _INTENT_KEY_DISPLAY_SEP)
100
+
101
+
102
+ class OrderStatus(StrEnum):
103
+ PENDING = "pending"
104
+ OPEN = "open"
105
+ PARTIALLY_FILLED = "partial"
106
+ FILLED = "filled"
107
+ CANCELLED = "cancelled"
108
+ REJECTED = "rejected"
109
+ EXPIRED = "expired"
110
+
111
+
112
+ class OrderType(StrEnum):
113
+ MARKET = "market"
114
+ LIMIT = "limit"
115
+ STOP = "stop"
116
+ TRAILING_STOP = "trailing_stop"
117
+
118
+
119
+ class LegType(StrEnum):
120
+ ENTRY = "entry"
121
+ TAKE_PROFIT = "tp"
122
+ STOP_LOSS = "sl"
123
+ TRAILING_STOP = "trail"
124
+ CLOSE = "close"
125
+
126
+
127
+ class OcaType(StrEnum):
128
+ """Canonical OCA semantics for the sync engine.
129
+
130
+ The Pine-level literal values (``strategy.oca.cancel`` / ``.reduce`` /
131
+ ``.none``) are plain strings for script compatibility; this enum is the
132
+ single authority the sync engine and intent builder match against. Adding
133
+ a new OCA semantic therefore requires exactly one source edit, not a
134
+ scattered grep across the intent-builder / sync-engine / validator.
135
+ """
136
+ CANCEL = "cancel"
137
+ REDUCE = "reduce"
138
+ NONE = "none"
139
+
140
+
141
+ class OcaPartialFillPolicy(StrEnum):
142
+ """How the sync engine treats a *partial* fill for OCA-cancel cascading.
143
+
144
+ On a full fill the behaviour is unambiguous: sibling orders in the same
145
+ OCA-cancel group must be cancelled. Partial fills are the grey zone — some
146
+ exchanges re-fill the remainder at a better price (risking sibling fills
147
+ too), others do not. The policy lets the user pick:
148
+
149
+ - :data:`FILL_CANCELS` (default): a partial fill already commits the script
150
+ to this side, so sibling cancel triggers immediately. Matches the
151
+ Pine backtester, where the first touch on any leg wins.
152
+ - :data:`FULL_FILL_ONLY`: wait until the leg is fully filled. Useful when
153
+ the user prefers siblings to stay live in case the first leg partial is
154
+ followed by a same-bar reversal that would otherwise lock in a
155
+ sub-optimal entry.
156
+ """
157
+ FILL_CANCELS = "fill_cancels"
158
+ FULL_FILL_ONLY = "full_fill_only"
159
+
160
+
161
+ class CapabilityLevel(StrEnum):
162
+ """Tri-tone declaration of how a plugin upholds a single capability.
163
+
164
+ The plugin advertises *what it can deliver end-to-end* (not raw exchange
165
+ support). The four levels are an at-a-glance summary the runner, sync
166
+ engine, validator and CLI can all consume:
167
+
168
+ - :data:`UNSUPPORTED` — neither the exchange nor the plugin can uphold the
169
+ semantics; :func:`~pynecore.core.broker.validation.validate_at_startup`
170
+ rejects scripts that need it. Default so a missing field never silently
171
+ advertises a capability.
172
+ - :data:`SOFTWARE` — upheld in the plugin / sync engine without any
173
+ exchange-side primitive (e.g. polling order state, software OCA cascade,
174
+ netting-based reduce-only). Validation passes; latency / failure
175
+ semantics are the plugin's responsibility.
176
+ - :data:`PARTIAL_NATIVE` — the exchange supports a *subset* of the
177
+ semantics natively, the plugin fills the rest in software. Used when a
178
+ capability has axes the exchange covers some but not all of (e.g.
179
+ Capital.com ``amend_order``: level / SL / TP amendable, ``size`` is not
180
+ and needs cancel+recreate). Validation passes; PARTIAL_NATIVE is a
181
+ diagnostic flag, not a stricter contract — the sync engine treats it
182
+ the same as SOFTWARE for fallback decisions.
183
+ - :data:`NATIVE` — single atomic exchange call delivers the full
184
+ semantics (e.g. Bybit attached TP/SL, exchange-side OCA group, native
185
+ reduce-only flag). The sync engine may suppress its software fallback
186
+ paths on this level (see :class:`OrderSyncEngine`).
187
+
188
+ The string values are stable and safe to log / persist (e.g. SQLite
189
+ columns, ``pyne plugin info`` output).
190
+ """
191
+ UNSUPPORTED = "unsupported"
192
+ SOFTWARE = "software"
193
+ PARTIAL_NATIVE = "partial_native"
194
+ NATIVE = "native"
195
+
196
+ @property
197
+ def is_supported(self) -> bool:
198
+ """``True`` for every level except :data:`UNSUPPORTED`.
199
+
200
+ The validator uses this to decide whether to reject a script — a
201
+ SOFTWARE-level capability is just as valid as NATIVE, only the cost /
202
+ latency / failure profile differs.
203
+ """
204
+ return self is not CapabilityLevel.UNSUPPORTED
205
+
206
+
207
+ # === Exchange state snapshots ===
208
+
209
+ @dataclass
210
+ class ExchangeOrder:
211
+ """An order as it exists on the exchange."""
212
+ id: str
213
+ symbol: str
214
+ side: str # "buy" | "sell"
215
+ order_type: OrderType
216
+ qty: float
217
+ filled_qty: float
218
+ remaining_qty: float
219
+ price: float | None # Limit price
220
+ stop_price: float | None # Trigger price
221
+ average_fill_price: float | None
222
+ status: OrderStatus
223
+ timestamp: float # Creation time (unix seconds)
224
+ fee: float
225
+ fee_currency: str
226
+ reduce_only: bool = False
227
+ # Exchange-side clientOrderId (our allocation, echoed back by the exchange).
228
+ # Required for post-restart bracket reconstruction — without it, open TP/SL
229
+ # legs left on the exchange cannot be mapped back to Pine identity.
230
+ client_order_id: str | None = None
231
+
232
+
233
+ @dataclass
234
+ class OrderEvent:
235
+ """
236
+ A normalized fill/status event reported by a BrokerPlugin.
237
+
238
+ The plugin is responsible for mapping exchange-level events back to
239
+ Pine-level identity. A single Pine exit intent may become multiple
240
+ exchange orders (e.g. Bybit Partial TP/SL pairs), and only the plugin
241
+ knows the mapping.
242
+
243
+ **fill_qty is INCREMENTAL.** It is the quantity of *this* fill event,
244
+ not a running total. :meth:`BrokerPosition.record_fill` *adds* it to the
245
+ position (it never diffs against the cumulative ``order.filled_qty``), so
246
+ a plugin that reported ``fill_qty`` cumulatively across partials would
247
+ over-apply. The cumulative-on-the-order total lives in
248
+ :attr:`ExchangeOrder.filled_qty`; the per-event slice lives here.
249
+
250
+ **fill_id is the canonical idempotency key.** The sync engine keeps a
251
+ bounded seen-set keyed on ``fill_id`` and drops a fill whose ``fill_id``
252
+ it has already applied, as a final defence against a broker delivering
253
+ the same execution twice (live-push/dispatch-response replay, poll+stream
254
+ race, reconnect). For this gate to work the invariant is: *the same real
255
+ broker execution MUST carry the same ``fill_id`` across every path that
256
+ can surface it.* Use the broker-native execution/deal id whenever one
257
+ exists. ``fill_id`` may be ``None`` only when the plugin has no
258
+ broker-native execution id for that path (e.g. a cumulative-only
259
+ reconcile/bridge emission); in that case the plugin MUST guarantee via
260
+ its persisted ``filled_qty`` cursor that no other path re-emits the same
261
+ execution. A ``None`` ``fill_id`` is a no-op for the gate (applied as-is),
262
+ so it must never be used for a path that a duplicate could also reach.
263
+ """
264
+ order: ExchangeOrder
265
+ event_type: str # "created" | "amended" | "filled" | "partial" | "cancelled" | "rejected"
266
+ fill_price: float | None
267
+ fill_qty: float | None # INCREMENTAL qty of THIS event (see class docstring)
268
+ timestamp: float
269
+ # Pine-level identity (filled by the plugin, used by sync engine + BrokerPosition)
270
+ pine_id: str | None = None # Pine order ID (entry id or exit id)
271
+ from_entry: str | None = None # Which entry this fill belongs to (for exits)
272
+ leg_type: LegType | None = None # Which leg of a bracket filled
273
+ fee: float = 0.0
274
+ fee_currency: str = ""
275
+ # Stable broker-native execution/deal id (see class docstring). Canonical
276
+ # across every path that can surface the same execution; the sync engine's
277
+ # duplicate-fill gate keys on it. ``None`` => no-op for the gate.
278
+ fill_id: str | None = None
279
+ # Set only on ``cancelled`` events synthesised by the
280
+ # :class:`~pynecore.core.broker.disappearance.DisappearanceTracker` after
281
+ # it has already applied the ``on_unexpected_cancel`` policy for the
282
+ # disappearance. The sync engine's WS-push handler applies the SAME policy
283
+ # for venue-pushed external cancels (``False`` here), so this flag gates
284
+ # the handler OFF for the tracker's own events — re-applying would double
285
+ # the audit / sibling sweep and raise the ``halt`` from the wrong place.
286
+ from_disappearance_tracker: bool = False
287
+
288
+ def __str__(self) -> str:
289
+ parts = [
290
+ self.event_type.upper(),
291
+ f"id={self.order.id}",
292
+ f"side={self.order.side}",
293
+ f"qty={self.order.qty}",
294
+ f"filled={self.order.filled_qty}",
295
+ ]
296
+ if self.fill_price is not None:
297
+ parts.append(f"price={self.fill_price}")
298
+ if self.pine_id:
299
+ parts.append(f"pine={self.pine_id!r}")
300
+ if self.from_entry:
301
+ parts.append(f"from={self.from_entry!r}")
302
+ if self.leg_type is not None:
303
+ parts.append(f"leg={self.leg_type.value}")
304
+ return " ".join(parts)
305
+
306
+
307
+ @dataclass
308
+ class ExchangePosition:
309
+ """Current position on the exchange — the engine's read-side view.
310
+
311
+ Venue-agnostic. On futures/margin venues it mirrors the broker's
312
+ native position object. On spot venues no such object exists, so the
313
+ plugin synthesizes it from its own fill ledger: ``side`` long/flat,
314
+ ``size`` = net base inventory, ``entry_price`` = ledger VWAP,
315
+ ``unrealized_pnl`` = (mark − VWAP) × size, ``leverage=1.0``,
316
+ ``liquidation_price=None``, ``margin_mode="cash"``.
317
+
318
+ The engine's reconcile consumes ``size``, ``side``, ``entry_price``
319
+ and ``unrealized_pnl``; the remaining fields are informational.
320
+ """
321
+ symbol: str
322
+ side: str # "long" | "short" | "flat"
323
+ size: float
324
+ entry_price: float
325
+ unrealized_pnl: float
326
+ liquidation_price: float | None
327
+ leverage: float
328
+ margin_mode: str # "cross" | "isolated" | "cash" (spot)
329
+
330
+
331
+ @dataclass
332
+ class PositionLeg:
333
+ """One raw open position ("leg") on a hedging-capable exchange.
334
+
335
+ A hedging account can hold several simultaneous positions for the same
336
+ symbol — each its own broker position id, opened by a single order. Pine
337
+ Script sees only one net one-way position per symbol, so the core
338
+ :mod:`~pynecore.core.broker.emulator` aggregates these legs for reads and
339
+ selects among them (oldest first) for reduce / close / reversal operations.
340
+
341
+ A plugin that opts into one-way emulation returns these via its
342
+ ``fetch_raw_positions`` transport primitive and performs ZERO aggregation
343
+ itself: on a hedging account it returns one leg per open broker position;
344
+ on a netting account it returns at most one.
345
+
346
+ :ivar leg_id: Broker-native position identifier (e.g. cTrader
347
+ ``positionId`` as a string), used to address the leg in ``close_leg``.
348
+ :ivar symbol: The Pine symbol this leg belongs to.
349
+ :ivar side: Direction that opened the leg — ``"buy"`` (long) or
350
+ ``"sell"`` (short). Matches :class:`ExchangeOrder` side wording.
351
+ :ivar qty: Open size in Pine units (always positive; ``side`` carries the
352
+ direction).
353
+ :ivar entry_price: Volume-weighted open price of the leg.
354
+ :ivar open_time: Leg open time (unix seconds). The FIFO close order is
355
+ derived from this, so it MUST be a stable, broker-reported value
356
+ (not a local wall clock) for replay determinism.
357
+ :ivar unrealized_pnl: Broker-reported mark-to-market P&L for this leg.
358
+ """
359
+ leg_id: str
360
+ symbol: str
361
+ side: str # "buy" (long) | "sell" (short)
362
+ qty: float
363
+ entry_price: float
364
+ open_time: float
365
+ unrealized_pnl: float = 0.0
366
+
367
+
368
+ @dataclass
369
+ class ExchangeCapabilities:
370
+ """
371
+ What the plugin can deliver end-to-end for the script, not raw exchange
372
+ support. Declared once at startup. Each capability is a
373
+ :class:`CapabilityLevel` — :data:`~CapabilityLevel.UNSUPPORTED` (default)
374
+ rejects scripts that need it via
375
+ :func:`~pynecore.core.broker.validation.validate_at_startup`; any other
376
+ level passes the validator. The level distinction
377
+ (NATIVE / PARTIAL_NATIVE / SOFTWARE) is a diagnostic — the sync engine
378
+ treats NATIVE as "exchange owns this; suppress my software fallback"
379
+ for the fields it explicitly checks (``oca_cancel``, ``tp_sl_bracket``),
380
+ and everything else as "engine still runs the fallback path". Plugins
381
+ that can guarantee end-to-end atomic delivery should declare NATIVE so
382
+ the engine can skip its emulation.
383
+ """
384
+ # === Order types ===
385
+ # NATIVE = exchange has the order type as a first-class primitive.
386
+ # SOFTWARE = plugin emulates it (e.g. a poll loop that converts a
387
+ # client-side trigger price into a market submit). UNSUPPORTED rejects
388
+ # scripts that use the corresponding Pine parameter.
389
+ #
390
+ # A both-set Pine entry (``strategy.entry(limit=, stop=)``) is NOT a
391
+ # single stop-limit primitive — it is two OCO legs. The broker layer
392
+ # places the LIMIT leg as a native resting order and arms the STOP leg
393
+ # as a software price-watch that fires a MARKET order, so no dedicated
394
+ # stop-limit capability is needed.
395
+ stop_order: CapabilityLevel = CapabilityLevel.UNSUPPORTED
396
+ # NATIVE = server-side trailing stop (e.g. Capital.com
397
+ # ``trailingStop=true, stopDistance``). SOFTWARE = plugin tracks the
398
+ # last extreme and amends the SL each tick / bar.
399
+ trailing_stop: CapabilityLevel = CapabilityLevel.UNSUPPORTED
400
+
401
+ # === Exit bracket (TP+SL with OCA reduce semantics) ===
402
+ # NATIVE = single atomic exchange call attaches both legs (Bybit V5
403
+ # attached TP/SL, Capital.com position-attribute bracket). The sync
404
+ # engine's partial-fill bracket-amend path is suppressed at this level.
405
+ # PARTIAL_NATIVE = the exchange takes one leg natively but the other
406
+ # requires a follow-up call (rare — e.g. SL on the position but TP only
407
+ # as a separate working order). Engine fallback stays active.
408
+ # SOFTWARE = the plugin issues two reduce-only orders and runs the
409
+ # OCA-reduce / cascade-cancel logic itself.
410
+ tp_sl_bracket: CapabilityLevel = CapabilityLevel.UNSUPPORTED
411
+
412
+ # Mechanism the plugin uses to deliver a partial-qty exit bracket
413
+ # (``strategy.exit(qty=N, from_entry="L", ...)`` with ``N`` less than
414
+ # the full row qty entered under ``"L"``).
415
+ # NATIVE = the exchange supports partial-qty bracket inside a single
416
+ # position as a first-class primitive (placeholder; no current broker
417
+ # delivers this).
418
+ # SOFTWARE = the plugin delivers the bracket without a single native
419
+ # call. The engine drives an in-memory leg state machine per
420
+ # ``strategy.exit`` and dispatches a partial close through the plugin's
421
+ # ``execute_close`` route when the trigger level is crossed. Used by
422
+ # per-deal CFD brokers (Capital.com, IG, OANDA).
423
+ # UNSUPPORTED = the validator rejects such scripts at startup rather
424
+ # than silently covering the whole row.
425
+ partial_qty_bracket_exit: CapabilityLevel = CapabilityLevel.UNSUPPORTED
426
+
427
+ # Companion to :attr:`partial_qty_bracket_exit`: the level at which the
428
+ # plugin routes a partial-qty bracket correctly when multiple parent
429
+ # positions share one Pine ``entry_id`` "L" — the script's
430
+ # ``pyramiding > 1``, or ``strategy.order()`` (which can open multiple
431
+ # same-id rows even at ``pyramiding = 1``).
432
+ # NATIVE = the exchange routes the multi-row reduction as a first-class
433
+ # primitive (placeholder; no current broker delivers this).
434
+ # SOFTWARE = the plugin delivers it without a single native call — the
435
+ # engine drives one delta close against the summed parent and the
436
+ # exchange reduces the rows itself (e.g. Capital.com: server-side FIFO
437
+ # reduction, no client-side per-``dealId`` routing — §9 #13).
438
+ # UNSUPPORTED = the validator rejects ``pyramiding > 1`` /
439
+ # ``strategy.order()`` scripts that need partial-qty bracket support,
440
+ # rather than silently routing against just the latest row's quantity.
441
+ partial_qty_bracket_exit_pyramiding: CapabilityLevel = CapabilityLevel.UNSUPPORTED
442
+
443
+ # === OCA cancel groups ===
444
+ # NATIVE = the exchange tracks the OCA group and cancels siblings on
445
+ # fill (Bybit bracket, OKX algo orders). The sync engine SUPPRESSES its
446
+ # cascade-cancel logic — exchange is authoritative.
447
+ # SOFTWARE = the engine emits CancelIntent dispatches itself when a
448
+ # leg fills (the default for the vast majority of exchanges, including
449
+ # Capital.com — its position-attribute bracket is a separate capability
450
+ # under ``tp_sl_bracket``, not OCA).
451
+ # UNSUPPORTED = the plugin cannot deliver OCA-cancel semantics at all
452
+ # — scripts using ``oca_type='cancel'`` are rejected at startup.
453
+ oca_cancel: CapabilityLevel = CapabilityLevel.UNSUPPORTED
454
+
455
+ # === Order management ===
456
+ # NATIVE = the exchange amends every parameter (price, size, SL/TP).
457
+ # PARTIAL_NATIVE = some axes are amendable, others (commonly ``size``)
458
+ # require cancel+recreate (Capital.com is exactly this case — level /
459
+ # SL / TP fields amend in-place, ``size`` does not).
460
+ # SOFTWARE = no in-place amend; plugin always cancel+recreate.
461
+ amend_order: CapabilityLevel = CapabilityLevel.UNSUPPORTED
462
+
463
+ # NATIVE = single batch call (e.g. Binance ``DELETE /openOrders``).
464
+ # SOFTWARE = plugin iterates per-id cancel under the hood.
465
+ cancel_all: CapabilityLevel = CapabilityLevel.UNSUPPORTED
466
+
467
+ # NATIVE = exchange honours an explicit reduce-only flag on the order.
468
+ # SOFTWARE = upheld via netting / one-way mode (plugin maps an
469
+ # opposite-side order onto the existing position so it cannot flip).
470
+ # UNSUPPORTED = scripts using ``strategy.exit`` / ``strategy.close`` are
471
+ # rejected at startup.
472
+ reduce_only: CapabilityLevel = CapabilityLevel.UNSUPPORTED
473
+
474
+ # === Streaming & position ===
475
+ # NATIVE = a real WebSocket order channel.
476
+ # SOFTWARE = plugin emulates the stream by polling REST endpoints and
477
+ # diffing snapshots (Capital.com ``GET /workingorders`` +
478
+ # ``GET /positions`` cadence).
479
+ watch_orders: CapabilityLevel = CapabilityLevel.UNSUPPORTED
480
+
481
+ # NATIVE = a single REST/WS read returns the position state.
482
+ # SOFTWARE = plugin reconstructs by aggregating fills locally.
483
+ # The distinction is mostly informational — both deliver the same
484
+ # contract.
485
+ fetch_position: CapabilityLevel = CapabilityLevel.UNSUPPORTED
486
+
487
+ # === Idempotency ===
488
+ # NATIVE = the exchange accepts a client-supplied order id, echoes it
489
+ # back on subsequent reads, AND rejects duplicate submissions of the
490
+ # same id (Binance/Bybit/OKX). The sync engine and recovery path can
491
+ # rely on the exchange to dedup retries after a timeout / restart.
492
+ # PARTIAL_NATIVE = the exchange echoes the client id but does NOT dedup
493
+ # duplicates; the plugin must dedup client-side before each dispatch
494
+ # (Interactive Brokers, Deribit).
495
+ # SOFTWARE = the exchange generates the id (Capital.com server-side
496
+ # ``dealReference``); the plugin dedups in its own SQLite store using
497
+ # :attr:`DispatchEnvelope.client_order_id` as the local key.
498
+ # Restart-safe recovery still works, just without exchange-side
499
+ # enforcement.
500
+ # UNSUPPORTED = neither echo nor dedup — restart/timeout retries are
501
+ # unsafe; live scripts are rejected at startup.
502
+ idempotency: CapabilityLevel = CapabilityLevel.UNSUPPORTED
503
+
504
+ # === Short selling ===
505
+ # NATIVE = the venue holds a signed position natively (margin / futures /
506
+ # CFD — selling more than the current long flips the book short).
507
+ # SOFTWARE = the plugin delivers short exposure through its own borrow /
508
+ # margin mechanism while the venue itself is spot-settled (placeholder;
509
+ # no current broker delivers this).
510
+ # UNSUPPORTED = the venue cannot hold a negative base position (spot).
511
+ # Scripts whose detected requirements include ``may_go_short`` are
512
+ # rejected at startup, and the sync engine arms its projected-position
513
+ # runtime gate: any dispatch that would take the aggregate signed
514
+ # position below zero halts the engine (graceful stop, never a silent
515
+ # skip). Mutually exclusive with a declared spot inventory port — the
516
+ # spot ledger models long-only exposure.
517
+ short_selling: CapabilityLevel = CapabilityLevel.UNSUPPORTED
518
+
519
+
520
+ # === Pine Script intents ===
521
+
522
+ @dataclass(frozen=True)
523
+ class EntryIntent:
524
+ """What the script wants: open or add to a position."""
525
+ pine_id: str # strategy.entry(id=...) or strategy.order(id=...)
526
+ symbol: str
527
+ side: str # "buy" | "sell"
528
+ qty: float
529
+ order_type: OrderType
530
+ limit: float | None = None # Limit price
531
+ stop: float | None = None # Trigger/activation price
532
+ oca_name: str | None = None # OCA group name (strategy.entry/order oca_name param)
533
+ oca_type: str | None = None # "reduce" | "cancel" | "none" (strategy.oca.*)
534
+ comment: str | None = None
535
+ alert_message: str | None = None
536
+ is_strategy_order: bool = False # True if from strategy.order() (no pyramiding limit)
537
+ # ``True`` only on the synthetic MARKET intent the entry-stop engine
538
+ # dispatches when the STOP leg of a both-set Pine entry fires (see
539
+ # :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`).
540
+ # The plugin uses it to pick
541
+ # :data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP` — a distinct
542
+ # client-order-id from the native LIMIT leg's
543
+ # :data:`~pynecore.core.broker.idempotency.KIND_ENTRY` — so the stop-fired
544
+ # market and the just-cancelled limit never share an idempotency key (which
545
+ # would make the broker's local dedup skip the market POST). ``compare=False``
546
+ # keeps it out of the diff equality: it is a dispatch route selector derived
547
+ # by the engine, not Pine-level state the diff needs to sync.
548
+ stop_fired_market: bool = field(default=False, compare=False)
549
+
550
+ @property
551
+ def intent_key(self) -> str:
552
+ """Stable diff key for the sync engine."""
553
+ return self.pine_id
554
+
555
+ def __str__(self) -> str:
556
+ parts = [
557
+ f"ENTRY {self.side.upper()} id={self.pine_id!r}",
558
+ f"qty={self.qty}",
559
+ f"type={self.order_type.value}",
560
+ ]
561
+ if self.limit is not None:
562
+ parts.append(f"limit={self.limit}")
563
+ if self.stop is not None:
564
+ parts.append(f"stop={self.stop}")
565
+ return " ".join(parts)
566
+
567
+
568
+ @dataclass(frozen=True)
569
+ class ExitIntent:
570
+ """What the script wants: reduce/close a position via TP/SL bracket."""
571
+ pine_id: str # strategy.exit(id=...)
572
+ from_entry: str # strategy.exit(from_entry=...)
573
+ symbol: str
574
+ side: str # Exit side ("sell" for long TP/SL, "buy" for short TP/SL)
575
+ qty: float
576
+ tp_price: float | None = None # Take-profit limit price
577
+ sl_price: float | None = None # Stop-loss trigger price
578
+ trail_price: float | None = None # Trailing stop activation price
579
+ trail_offset: float | None = None # Trailing stop offset (price units)
580
+ # Raw tick values — used when exit is against a pending (unfilled) entry,
581
+ # so absolute prices cannot be calculated yet. The Order Sync Engine
582
+ # converts these to tp_price/sl_price after the entry fills.
583
+ profit_ticks: float | None = None
584
+ loss_ticks: float | None = None
585
+ trail_points_ticks: float | None = None
586
+ oca_name: str | None = None # OCA group name (auto: __exit_{id}_{from_entry}_oca__)
587
+ oca_type: str | None = None # "reduce" | "cancel" | "none" (default: reduce for exits)
588
+ comment: str | None = None
589
+ comment_profit: str | None = None
590
+ comment_loss: str | None = None
591
+ comment_trailing: str | None = None
592
+ alert_message: str | None = None
593
+ # One-way Pine semantics: every strategy.exit is reduce-only by definition.
594
+ # A manual position close while the exit is pending must not flip the
595
+ # book back to the other side. The plugin must pass this to the exchange
596
+ # (Binance/Bybit/OKX ``reduceOnly``, Capital.com force-close, etc.).
597
+ # ``False`` is rejected at construction — a future ``HedgeBrokerPlugin``
598
+ # subclass will introduce a separate hedge-aware intent rather than flip
599
+ # this flag.
600
+ reduce_only: bool = True
601
+ # ``True`` when the script asked for a TP/SL/trailing bracket on a
602
+ # *fraction* of the parent entry's declared quantity
603
+ # (``strategy.exit(qty=N, from_entry='L', tp=...)`` with ``N`` strictly
604
+ # less than the declared total under ``'L'``). The intent builder
605
+ # fills this from ``position.entry_orders[from_entry].size`` — the
606
+ # script's declaration, not the broker's actual fill — falling back
607
+ # to the ``open_trades`` aggregate when the entry Order has been
608
+ # cancelled / cleared. The order sync engine dispatches on this flag
609
+ # together with ``caps.partial_qty_bracket_exit`` to pick between the
610
+ # native bracket path (NATIVE) and the engine-side trigger state
611
+ # machine (SOFTWARE). A bracket on the whole row keeps this ``False``
612
+ # and falls back to the existing full-row path.
613
+ #
614
+ # ``compare=False`` deliberately excludes the flag from
615
+ # :meth:`__eq__` / :meth:`__hash__`: it is a *route selector* derived
616
+ # from the intent, not state that needs to be diff-synced to the
617
+ # broker. The sync engine's qty-cap reconciliation
618
+ # (``_sync_pine_exit_qty``) mutates ``exit_orders[(exit_id, from_entry
619
+ # )].size`` after a partial entry fill so the next ``build_intents``
620
+ # produces an ExitIntent with the capped qty; including the derived
621
+ # flag in equality would let that cap mutation flip the flag and
622
+ # trigger a spurious second ``modify_exit`` dispatch even though
623
+ # nothing the broker needs to know has changed.
624
+ is_partial_qty_bracket: bool = field(default=False, compare=False)
625
+
626
+ def __post_init__(self) -> None:
627
+ if not self.reduce_only:
628
+ raise ValueError(
629
+ "ExitIntent.reduce_only must be True — one-way Pine semantics. "
630
+ "Hedge-mode intents belong on a future HedgeBrokerPlugin subclass."
631
+ )
632
+
633
+ @property
634
+ def intent_key(self) -> str:
635
+ """
636
+ Stable diff key for the sync engine.
637
+
638
+ Cannot be just pine_id — strategy.exit(id="TP") can create separate
639
+ exit orders for different from_entry values (e.g. "Long" and "Short").
640
+ The (pine_id, from_entry) tuple is the unique key.
641
+ """
642
+ return f"{self.pine_id}{INTENT_KEY_SEP}{self.from_entry}"
643
+
644
+ @property
645
+ def has_unresolved_ticks(self) -> bool:
646
+ """True if tick-based prices need entry fill price to resolve."""
647
+ return (self.profit_ticks is not None or self.loss_ticks is not None
648
+ or self.trail_points_ticks is not None)
649
+
650
+ def __str__(self) -> str:
651
+ parts = [
652
+ f"EXIT id={self.pine_id!r}",
653
+ f"from={self.from_entry!r}",
654
+ f"qty={self.qty}",
655
+ ]
656
+ if self.tp_price is not None:
657
+ parts.append(f"tp={self.tp_price}")
658
+ if self.sl_price is not None:
659
+ parts.append(f"sl={self.sl_price}")
660
+ if self.trail_price is not None or self.trail_offset is not None:
661
+ parts.append(f"trail={self.trail_price}/{self.trail_offset}")
662
+ return " ".join(parts)
663
+
664
+
665
+ @dataclass(frozen=True)
666
+ class CloseIntent:
667
+ """
668
+ What the script wants: close position with market order.
669
+
670
+ The ``immediately`` flag mirrors TradingView backtest semantics: without
671
+ it, a close waits for the next bar's open. With ``calc_on_every_tick``
672
+ a non-immediate close can delay execution by an entire bar in live
673
+ trading, which is why the flag exists.
674
+ """
675
+ pine_id: str # strategy.close(id=...) or strategy.close_all()
676
+ symbol: str
677
+ side: str # "sell" to close long, "buy" to close short
678
+ qty: float
679
+ immediately: bool = False
680
+ comment: str | None = None
681
+ alert_message: str | None = None
682
+ # Same invariant as :attr:`ExitIntent.reduce_only` — a close can never
683
+ # flip the book to the other side in one-way Pine mode.
684
+ reduce_only: bool = True
685
+
686
+ def __post_init__(self) -> None:
687
+ if not self.reduce_only:
688
+ raise ValueError(
689
+ "CloseIntent.reduce_only must be True — one-way Pine semantics."
690
+ )
691
+
692
+ @property
693
+ def intent_key(self) -> str:
694
+ return self.pine_id
695
+
696
+ def __str__(self) -> str:
697
+ return (
698
+ f"CLOSE id={self.pine_id!r} side={self.side} qty={self.qty}"
699
+ )
700
+
701
+
702
+ @dataclass(frozen=True)
703
+ class CancelIntent:
704
+ """
705
+ What the script wants: cancel a pending order.
706
+
707
+ ``strategy.cancel(id)`` cancels ALL orders matching that id. For exits
708
+ this means every (pine_id, from_entry) pair with that pine_id. The
709
+ Order Sync Engine resolves the affected intent_keys and may send
710
+ multiple CancelIntents (one per from_entry), or a single one with
711
+ ``from_entry=None`` meaning "cancel all exits with this pine_id".
712
+ """
713
+ pine_id: str
714
+ symbol: str
715
+ from_entry: str | None = None
716
+
717
+ @property
718
+ def intent_key(self) -> str:
719
+ if self.from_entry is not None:
720
+ return f"{self.pine_id}{INTENT_KEY_SEP}{self.from_entry}"
721
+ return self.pine_id
722
+
723
+ def __str__(self) -> str:
724
+ tail = f" from={self.from_entry!r}" if self.from_entry else ""
725
+ return f"CANCEL id={self.pine_id!r}{tail}"
726
+
727
+
728
+ # === Recovery contracts ===
729
+
730
+ @dataclass(frozen=True)
731
+ class BracketAttachRejectContext:
732
+ """Recovery context for a bracket attach reject after a parent fill.
733
+
734
+ Built by the sync engine when a plugin raises
735
+ :class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`
736
+ and threaded into
737
+ :meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_residual_orders_after_bracket_attach_reject`
738
+ so the plugin can enumerate any broker-side orders the engine must
739
+ cancel as part of defensive recovery.
740
+
741
+ Separation of concerns: the exception carries transport + debug info
742
+ (chained cause, message), this context carries the recovery contract
743
+ (what the engine needs to settle the defensive close and rebuild
744
+ state on restart). Frozen + hashable so it can live inside a
745
+ :class:`PendingDefensiveClose` marker that is persisted across
746
+ restarts via the BrokerStore extras column.
747
+
748
+ :ivar intent_key: Diff key of the original (rejected) intent — used
749
+ to correlate the defensive close with its trigger in the audit log.
750
+ :ivar position_coid: Client-order-id of the unprotected open position
751
+ (the parent ENTRY row in BrokerStore). Universal recovery key
752
+ across plugins.
753
+ :ivar position_side: Side of the OPEN position (``"buy"`` for long,
754
+ ``"sell"`` for short). The defensive close picks the opposite.
755
+ :ivar qty: Quantity of the unprotected position the close must
756
+ flatten.
757
+ :ivar symbol: Trading symbol of the unprotected position.
758
+ :ivar position_deal_id: Exchange-side identifier of the unprotected
759
+ position when the plugin can supply one. Broker-specific
760
+ (Capital.com deal id, IB permId, Bybit orderId, ...); recovery
761
+ logic must not rely on it for correctness — use ``position_coid``
762
+ for cross-broker lookups.
763
+ :ivar from_entry: Pine ``strategy.entry`` id when the rejected
764
+ bracket originated from a ``strategy.exit``. Populated by
765
+ :meth:`from_exception` from the exception or — as a fallback —
766
+ from the triggering intent.
767
+ :ivar exit_id: Pine ``strategy.exit`` id when applicable.
768
+ :ivar filled_qty: Quantity already filled on the parent at the time
769
+ of the reject. ``None`` falls back to ``qty`` (conservative).
770
+ :ivar error_code: Plugin-supplied exchange-side error code, if any.
771
+ :ivar error_message: Plugin-supplied exchange-side error message.
772
+ """
773
+ intent_key: str
774
+ position_coid: str
775
+ position_side: str
776
+ qty: float
777
+ symbol: str
778
+ position_deal_id: str | None = None
779
+ from_entry: str | None = None
780
+ exit_id: str | None = None
781
+ filled_qty: float | None = None
782
+ error_code: str | None = None
783
+ error_message: str | None = None
784
+
785
+ @classmethod
786
+ def from_exception(
787
+ cls,
788
+ e: 'BracketAttachAfterFillRejectedError',
789
+ intent: 'EntryIntent | ExitIntent | CloseIntent | CancelIntent',
790
+ ) -> 'BracketAttachRejectContext':
791
+ """Build a context from a raised exception + its triggering intent.
792
+
793
+ ``from_entry`` is taken from the exception when present;
794
+ otherwise it falls back to ``intent.from_entry`` for an
795
+ :class:`ExitIntent` or ``intent.pine_id`` for an
796
+ :class:`EntryIntent`. The optional plugin-supplied fields are
797
+ read via :func:`getattr` so plugins can adopt them at their own
798
+ pace without breaking the contract.
799
+ """
800
+ from_entry = e.from_entry
801
+ if from_entry is None:
802
+ if isinstance(intent, ExitIntent):
803
+ from_entry = intent.from_entry
804
+ elif isinstance(intent, EntryIntent):
805
+ from_entry = intent.pine_id
806
+ return cls(
807
+ intent_key=intent.intent_key,
808
+ position_coid=e.position_coid,
809
+ position_side=e.position_side,
810
+ qty=e.qty,
811
+ symbol=e.symbol,
812
+ position_deal_id=e.position_deal_id,
813
+ from_entry=from_entry,
814
+ exit_id=getattr(e, 'exit_id', None),
815
+ filled_qty=getattr(e, 'filled_qty', None),
816
+ error_code=getattr(e, 'error_code', None),
817
+ error_message=getattr(e, 'error_message', None),
818
+ )
819
+
820
+ def to_dict(self) -> dict:
821
+ """Serialize to a JSON-compatible dict for BrokerStore extras."""
822
+ return {
823
+ 'intent_key': self.intent_key,
824
+ 'position_coid': self.position_coid,
825
+ 'position_side': self.position_side,
826
+ 'qty': self.qty,
827
+ 'symbol': self.symbol,
828
+ 'position_deal_id': self.position_deal_id,
829
+ 'from_entry': self.from_entry,
830
+ 'exit_id': self.exit_id,
831
+ 'filled_qty': self.filled_qty,
832
+ 'error_code': self.error_code,
833
+ 'error_message': self.error_message,
834
+ }
835
+
836
+ @classmethod
837
+ def from_dict(cls, data: dict) -> 'BracketAttachRejectContext':
838
+ """Rebuild from a previously serialized ``to_dict`` payload.
839
+
840
+ Raises :class:`ValueError` if required fields are missing or
841
+ not of the expected primitive type — callers (startup replay)
842
+ catch this and log+skip the malformed extras row instead of
843
+ crashing the engine.
844
+ """
845
+ if not isinstance(data, dict):
846
+ raise ValueError(
847
+ f"BracketAttachRejectContext payload must be a dict, "
848
+ f"got {type(data).__name__}"
849
+ )
850
+ try:
851
+ intent_key = data['intent_key']
852
+ position_coid = data['position_coid']
853
+ position_side = data['position_side']
854
+ qty = data['qty']
855
+ symbol = data['symbol']
856
+ except KeyError as exc:
857
+ raise ValueError(
858
+ f"BracketAttachRejectContext payload missing required "
859
+ f"field {exc.args[0]!r}: {data!r}"
860
+ ) from exc
861
+ if not (isinstance(intent_key, str) and isinstance(position_coid, str)
862
+ and isinstance(position_side, str) and isinstance(symbol, str)):
863
+ raise ValueError(
864
+ f"BracketAttachRejectContext required string fields have "
865
+ f"wrong type: {data!r}"
866
+ )
867
+ if not isinstance(qty, (int, float)):
868
+ raise ValueError(
869
+ f"BracketAttachRejectContext.qty must be numeric: {data!r}"
870
+ )
871
+ filled_qty_raw = data.get('filled_qty')
872
+ if filled_qty_raw is not None and not isinstance(filled_qty_raw, (int, float)):
873
+ raise ValueError(
874
+ f"BracketAttachRejectContext.filled_qty must be numeric or "
875
+ f"None: {data!r}"
876
+ )
877
+ return cls(
878
+ intent_key=intent_key,
879
+ position_coid=position_coid,
880
+ position_side=position_side,
881
+ qty=float(qty),
882
+ symbol=symbol,
883
+ position_deal_id=data.get('position_deal_id'),
884
+ from_entry=data.get('from_entry'),
885
+ exit_id=data.get('exit_id'),
886
+ filled_qty=(
887
+ float(filled_qty_raw)
888
+ if filled_qty_raw is not None else None
889
+ ),
890
+ error_code=data.get('error_code'),
891
+ error_message=data.get('error_message'),
892
+ )
893
+
894
+
895
+ @dataclass(frozen=True)
896
+ class PendingDefensiveClose:
897
+ """Engine-side marker for an in-flight defensive close.
898
+
899
+ Set by :meth:`OrderSyncEngine._handle_bracket_attach_after_fill_reject`
900
+ BEFORE the defensive close dispatches; cleared by the engine's FILL
901
+ handler when the close lands. While the marker lives, the entry
902
+ intent stays in ``_active_intents`` so a same-bar
903
+ :meth:`OrderSyncEngine.reconcile` cannot mistakenly classify the
904
+ flat broker snapshot as an external flatten — see the
905
+ ``defensive-close-pending-lifecycle`` design doc.
906
+
907
+ Persisted to the parent entry's BrokerStore row under
908
+ ``extras['defensive_close_pending']`` so a process restart between
909
+ dispatch and FILL can replay the marker; the engine's startup
910
+ replay then either drops the marker (FILL already recorded) or
911
+ re-arms residual cancel handling.
912
+
913
+ :ivar entry_id: Pine entry id this marker belongs to.
914
+ :ivar close_intent_key: Diff key of the synthetic defensive close.
915
+ :ivar close_order_ref: Broker-side order ref of the close, when the
916
+ plugin returned one. ``None`` if dispatch ran but the plugin did
917
+ not surface a ref (e.g. position-attached close on Capital.com).
918
+ :ivar close_client_order_id: Canonical ``client_order_id`` of the
919
+ synthetic close dispatch (derived from the close envelope). Set
920
+ as soon as the dispatch is parked or returns — used to match the
921
+ eventual FILL via :attr:`OrderEvent.order.client_order_id` when
922
+ ``pine_id`` is absent and the parked order never appeared in
923
+ :meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_open_orders`
924
+ (so :meth:`OrderSyncEngine._maybe_attach_defensive_close_ref`
925
+ never backfilled :attr:`close_order_ref`). Also used to drop the
926
+ parked pending row from
927
+ :attr:`OrderSyncEngine._pending_verification` (and the persisted
928
+ ``record_park`` row) once the close settles.
929
+ :ivar pending_since: ``time.time()`` at marker creation. Drives the
930
+ :meth:`OrderSyncEngine.reconcile` stale-pending grace check.
931
+ :ivar reject_context: Frozen snapshot of the
932
+ :class:`BracketAttachRejectContext` that produced the close —
933
+ used by startup replay to re-derive residual orders without
934
+ rebuilding the exception.
935
+ :ivar residual_cleanup_pending: ``True`` when the dispatch-time
936
+ residual-cancel call hit a transient
937
+ :class:`ExchangeConnectionError` /
938
+ :class:`OrderDispositionUnknownError` and could not finish
939
+ cancelling parent/TP/SL/partial-remainder orders. The next
940
+ :meth:`OrderSyncEngine.reconcile` replays
941
+ :meth:`OrderSyncEngine._cancel_bracket_reject_residuals` so the
942
+ residuals do not stay live until the FILL eventually arrives;
943
+ cleared once the retry finishes (or on FILL-time settlement,
944
+ whichever happens first).
945
+ :ivar fill_observed: ``True`` once
946
+ :meth:`OrderSyncEngine._route_defensive_close_fill` has matched
947
+ the defensive-close FILL event (and seeded the in-memory
948
+ duplicate-fill caches) but the final residual-cancel + audit
949
+ sequence has not yet completed. Persisted so that a crash in
950
+ the post-FILL settlement window does NOT lose the only record
951
+ that the close already filled — startup replay re-seeds the
952
+ duplicate-fill caches and routes the marker through the
953
+ FILL-side branch of
954
+ :meth:`OrderSyncEngine._retry_residual_cleanup_after_transient_fill`
955
+ rather than waiting for a FILL that will never re-arrive.
956
+ :ivar partial_filled_qty: Cumulative ``fill_qty`` already applied
957
+ to :attr:`OrderSyncEngine._position.size` by no-FIFO
958
+ defensive-close ``partial`` events. Set to zero on marker
959
+ creation; accumulates as partials arrive. Used by the terminal
960
+ ``filled`` event's missing/zero ``fill_qty`` fallback to derive
961
+ the remaining close qty (``reject_context.qty -
962
+ partial_filled_qty``) instead of re-applying the full marker
963
+ qty (which would double-subtract the partial slices already
964
+ accounted for).
965
+ :ivar unapplied_partial_qty: Cumulative ``fill_qty`` from no-FIFO
966
+ defensive-close ``partial`` events that the engine could NOT
967
+ apply to :attr:`OrderSyncEngine._position.size` because the
968
+ parent ENTRY fill had not yet arrived (``_position.size ==
969
+ 0.0``). Tracked separately from :attr:`partial_filled_qty` so
970
+ the terminal computation does not believe the slice was
971
+ already booked. When the parent ENTRY ``filled`` / ``partial``
972
+ event finally routes through
973
+ :meth:`OrderSyncEngine._route_event`, the engine subtracts the
974
+ signed accumulated qty from :attr:`_position.size` and moves
975
+ it onto :attr:`partial_filled_qty` so the terminal close FILL
976
+ sees a consistent state. ``0.0`` on marker creation and after
977
+ every drain.
978
+ :ivar fill_exchange_order_id: Broker-side ``order.id`` of the
979
+ actual FILL event observed by
980
+ :meth:`OrderSyncEngine._route_defensive_close_fill`. Persisted
981
+ alongside :attr:`fill_observed` so that startup replay can
982
+ re-seed :attr:`OrderSyncEngine._settled_defensive_close_order_refs`
983
+ with the fill id when the FILL arrived with an id different
984
+ from :attr:`close_order_ref` (polled-orders fallback / broker
985
+ rekey). Without persisting it, a delayed WS / polled-orders
986
+ replay of the same FILL post-restart whose
987
+ ``event.order.id`` differs from ``close_order_ref`` and does
988
+ not echo ``close_client_order_id`` would slip past
989
+ :meth:`OrderSyncEngine._is_duplicate_defensive_close_fill` and
990
+ be re-applied to ``BrokerPosition``. ``None`` until the FILL
991
+ is observed (or when the FILL event carried no ``order``).
992
+ :ivar pre_close_position_size: Signed
993
+ :attr:`OrderSyncEngine._position.size` captured at marker
994
+ creation, before the defensive close dispatched. Used by
995
+ startup replay to distinguish a broker snapshot that still
996
+ reports the pre-close aggregate (close has not landed yet)
997
+ from one that already reflects the close (close landed before
998
+ crash but the FILL was not observed by the prior process —
999
+ ``fill_observed`` would otherwise stay ``False``). Without it,
1000
+ the post-restart reconcile cannot safely adopt the broker's
1001
+ reduced-but-not-flat size into :attr:`_position.size` because
1002
+ the no-FIFO settle branch would treat the adopted size as
1003
+ pre-close and over-reduce when the delayed FILL routes.
1004
+ ``None`` only for markers persisted by an older schema; the
1005
+ replay path falls back to the conservative "skip adoption"
1006
+ behaviour in that case.
1007
+ :ivar fifo_closed_entry_ids: Snapshot of the entry ids whose FIFO
1008
+ ``Trade`` rows were closed by :meth:`record_fill` for this
1009
+ defensive close, accumulated across every ``partial`` /
1010
+ terminal ``filled`` event of the close sequence. The
1011
+ ``t == 'partial'`` branch in
1012
+ :meth:`OrderSyncEngine._route_event` merges each partial's
1013
+ FIFO closures here, and
1014
+ :meth:`OrderSyncEngine._route_defensive_close_fill` merges the
1015
+ terminal slice on top BEFORE the residual-cancel attempt — so
1016
+ in pyramiding (LongA closed by a partial, LongB closed by the
1017
+ terminal) both entries are persisted, not only the terminal's.
1018
+ Persisted so that
1019
+ :meth:`OrderSyncEngine._retry_residual_cleanup_after_transient_fill`
1020
+ can run the same FIFO-aware
1021
+ :meth:`OrderSyncEngine._cleanup_position_tracking` walk as the
1022
+ in-flight FILL path even when intervening
1023
+ :meth:`record_fill` calls have overwritten the live
1024
+ ``_last_fifo_closed_entry_ids`` snapshot. Empty when the FILL
1025
+ produced no FIFO closures (no-FIFO / degenerate path) or when
1026
+ the marker is replayed from an older schema; the retry path
1027
+ falls back to ``marker.entry_id`` in that case.
1028
+ """
1029
+ entry_id: str
1030
+ close_intent_key: str
1031
+ close_order_ref: str | None
1032
+ pending_since: float
1033
+ reject_context: BracketAttachRejectContext
1034
+ close_client_order_id: str | None = None
1035
+ residual_cleanup_pending: bool = False
1036
+ fill_observed: bool = False
1037
+ partial_filled_qty: float = 0.0
1038
+ fill_exchange_order_id: str | None = None
1039
+ pre_close_position_size: float | None = None
1040
+ fifo_closed_entry_ids: tuple[str, ...] = ()
1041
+ unapplied_partial_qty: float = 0.0
1042
+
1043
+ def to_extras_dict(self) -> dict:
1044
+ """Serialize to a JSON-compatible dict for ``extras`` storage."""
1045
+ return {
1046
+ 'entry_id': self.entry_id,
1047
+ 'close_intent_key': self.close_intent_key,
1048
+ 'close_order_ref': self.close_order_ref,
1049
+ 'close_client_order_id': self.close_client_order_id,
1050
+ 'pending_since': self.pending_since,
1051
+ 'reject_context': self.reject_context.to_dict(),
1052
+ 'residual_cleanup_pending': self.residual_cleanup_pending,
1053
+ 'fill_observed': self.fill_observed,
1054
+ 'partial_filled_qty': self.partial_filled_qty,
1055
+ 'fill_exchange_order_id': self.fill_exchange_order_id,
1056
+ 'pre_close_position_size': self.pre_close_position_size,
1057
+ 'fifo_closed_entry_ids': list(self.fifo_closed_entry_ids),
1058
+ 'unapplied_partial_qty': self.unapplied_partial_qty,
1059
+ }
1060
+
1061
+ @classmethod
1062
+ def from_extras_dict(cls, data: dict) -> 'PendingDefensiveClose':
1063
+ """Rebuild from a previously serialized ``to_extras_dict`` payload.
1064
+
1065
+ Raises :class:`ValueError` on malformed input — the startup
1066
+ replay catches this and logs + skips the row.
1067
+ """
1068
+ if not isinstance(data, dict):
1069
+ raise ValueError(
1070
+ f"PendingDefensiveClose payload must be a dict, "
1071
+ f"got {type(data).__name__}"
1072
+ )
1073
+ try:
1074
+ entry_id = data['entry_id']
1075
+ close_intent_key = data['close_intent_key']
1076
+ pending_since = data['pending_since']
1077
+ ctx_payload = data['reject_context']
1078
+ except KeyError as exc:
1079
+ raise ValueError(
1080
+ f"PendingDefensiveClose payload missing required "
1081
+ f"field {exc.args[0]!r}: {data!r}"
1082
+ ) from exc
1083
+ if not (isinstance(entry_id, str) and isinstance(close_intent_key, str)):
1084
+ raise ValueError(
1085
+ f"PendingDefensiveClose required string fields have "
1086
+ f"wrong type: {data!r}"
1087
+ )
1088
+ if not isinstance(pending_since, (int, float)):
1089
+ raise ValueError(
1090
+ f"PendingDefensiveClose.pending_since must be numeric: "
1091
+ f"{data!r}"
1092
+ )
1093
+ close_order_ref = data.get('close_order_ref')
1094
+ if close_order_ref is not None and not isinstance(close_order_ref, str):
1095
+ raise ValueError(
1096
+ f"PendingDefensiveClose.close_order_ref must be str or "
1097
+ f"None: {data!r}"
1098
+ )
1099
+ close_client_order_id = data.get('close_client_order_id')
1100
+ if (close_client_order_id is not None
1101
+ and not isinstance(close_client_order_id, str)):
1102
+ raise ValueError(
1103
+ f"PendingDefensiveClose.close_client_order_id must be str "
1104
+ f"or None: {data!r}"
1105
+ )
1106
+ residual_cleanup_pending = bool(
1107
+ data.get('residual_cleanup_pending', False),
1108
+ )
1109
+ fill_observed = bool(data.get('fill_observed', False))
1110
+ partial_filled_qty_raw = data.get('partial_filled_qty', 0.0)
1111
+ if not isinstance(partial_filled_qty_raw, (int, float)):
1112
+ raise ValueError(
1113
+ f"PendingDefensiveClose.partial_filled_qty must be "
1114
+ f"numeric: {data!r}"
1115
+ )
1116
+ partial_filled_qty = float(partial_filled_qty_raw)
1117
+ if partial_filled_qty < 0.0:
1118
+ raise ValueError(
1119
+ f"PendingDefensiveClose.partial_filled_qty must be "
1120
+ f"non-negative: {data!r}"
1121
+ )
1122
+ fill_exchange_order_id = data.get('fill_exchange_order_id')
1123
+ if (fill_exchange_order_id is not None
1124
+ and not isinstance(fill_exchange_order_id, str)):
1125
+ raise ValueError(
1126
+ f"PendingDefensiveClose.fill_exchange_order_id must be "
1127
+ f"str or None: {data!r}"
1128
+ )
1129
+ pre_close_position_size_raw = data.get('pre_close_position_size')
1130
+ if pre_close_position_size_raw is None:
1131
+ pre_close_position_size: float | None = None
1132
+ elif isinstance(pre_close_position_size_raw, (int, float)):
1133
+ pre_close_position_size = float(pre_close_position_size_raw)
1134
+ else:
1135
+ raise ValueError(
1136
+ f"PendingDefensiveClose.pre_close_position_size must be "
1137
+ f"numeric or None: {data!r}"
1138
+ )
1139
+ fifo_closed_entry_ids_raw = data.get('fifo_closed_entry_ids', [])
1140
+ if not isinstance(fifo_closed_entry_ids_raw, (list, tuple)):
1141
+ raise ValueError(
1142
+ f"PendingDefensiveClose.fifo_closed_entry_ids must be "
1143
+ f"a list or tuple: {data!r}"
1144
+ )
1145
+ fifo_closed_entry_ids_list: list[str] = []
1146
+ for entry in fifo_closed_entry_ids_raw:
1147
+ if not isinstance(entry, str):
1148
+ raise ValueError(
1149
+ f"PendingDefensiveClose.fifo_closed_entry_ids items "
1150
+ f"must be strings: {data!r}"
1151
+ )
1152
+ fifo_closed_entry_ids_list.append(entry)
1153
+ unapplied_partial_qty_raw = data.get('unapplied_partial_qty', 0.0)
1154
+ if not isinstance(unapplied_partial_qty_raw, (int, float)):
1155
+ raise ValueError(
1156
+ f"PendingDefensiveClose.unapplied_partial_qty must be "
1157
+ f"numeric: {data!r}"
1158
+ )
1159
+ unapplied_partial_qty = float(unapplied_partial_qty_raw)
1160
+ if unapplied_partial_qty < 0.0:
1161
+ raise ValueError(
1162
+ f"PendingDefensiveClose.unapplied_partial_qty must be "
1163
+ f"non-negative: {data!r}"
1164
+ )
1165
+ return cls(
1166
+ entry_id=entry_id,
1167
+ close_intent_key=close_intent_key,
1168
+ close_order_ref=close_order_ref,
1169
+ pending_since=float(pending_since),
1170
+ reject_context=BracketAttachRejectContext.from_dict(ctx_payload),
1171
+ close_client_order_id=close_client_order_id,
1172
+ residual_cleanup_pending=residual_cleanup_pending,
1173
+ fill_observed=fill_observed,
1174
+ partial_filled_qty=partial_filled_qty,
1175
+ fill_exchange_order_id=fill_exchange_order_id,
1176
+ pre_close_position_size=pre_close_position_size,
1177
+ fifo_closed_entry_ids=tuple(fifo_closed_entry_ids_list),
1178
+ unapplied_partial_qty=unapplied_partial_qty,
1179
+ )
1180
+
1181
+
1182
+ # === Dispatch envelope ===
1183
+
1184
+ @dataclass(frozen=True)
1185
+ class DispatchEnvelope:
1186
+ """Broker dispatch envelope — an intent plus idempotency metadata.
1187
+
1188
+ The :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine` wraps every
1189
+ intent in a fresh envelope before handing it to the :class:`BrokerPlugin`.
1190
+ Plugins call :meth:`client_order_id` for each exchange order they place;
1191
+ the result is deterministic, so a retry or restart regenerates the same id
1192
+ and the exchange dedups the duplicate.
1193
+
1194
+ :ivar intent: The Pine-level intent this dispatch carries.
1195
+ :ivar run_tag: 4-char base36 session tag (see
1196
+ :meth:`~pynecore.core.broker.run_identity.RunIdentity.make_run_tag`).
1197
+ :ivar bar_ts_ms: Bar open timestamp (ms since Unix epoch).
1198
+ :ivar retry_seq: Bumped by the recovery path only when a prior attempt is
1199
+ deliberately abandoned — defaults to ``0``.
1200
+ :ivar coid_max_len: The venue's client-id budget (the plugin's
1201
+ :attr:`~pynecore.core.plugin.broker.BrokerPlugin.client_order_id_max_len`).
1202
+ When the canonical id exceeds it, :meth:`client_order_id` emits the
1203
+ deterministic wire form instead (see
1204
+ :mod:`pynecore.core.broker.idempotency`). The default keeps the
1205
+ canonical id untouched.
1206
+ """
1207
+ intent: 'EntryIntent | ExitIntent | CloseIntent | CancelIntent'
1208
+ run_tag: str
1209
+ bar_ts_ms: int
1210
+ retry_seq: int = 0
1211
+ coid_max_len: int = CLIENT_ORDER_ID_MAX_LEN
1212
+
1213
+ def client_order_id(self, kind: str) -> str:
1214
+ """Allocate the client-order-id for a given leg kind.
1215
+
1216
+ Canonical form, shortened to the venue's wire form when
1217
+ :attr:`coid_max_len` demands it — deterministic either way.
1218
+
1219
+ :param kind: One of the ``KIND_*`` constants from
1220
+ :mod:`pynecore.core.broker.idempotency`.
1221
+ """
1222
+ return encode_wire_client_order_id(
1223
+ build_client_order_id(
1224
+ run_tag=self.run_tag,
1225
+ pine_id=self._coid_identity(),
1226
+ bar_ts_ms=self.bar_ts_ms,
1227
+ kind=kind,
1228
+ retry_seq=self.retry_seq,
1229
+ ),
1230
+ self.coid_max_len,
1231
+ )
1232
+
1233
+ def _coid_identity(self) -> str:
1234
+ """Return the identity string hashed into the client-order-id.
1235
+
1236
+ For an :class:`EntryIntent` / :class:`CloseIntent` the Pine id alone
1237
+ uniquely identifies the dispatch. An :class:`ExitIntent` (and a
1238
+ per-entry :class:`CancelIntent`) shares one exit ``pine_id`` across
1239
+ every entry a global ``strategy.exit`` fans out to, so the id must
1240
+ also fold in ``from_entry`` — otherwise two distinct protective
1241
+ brackets on a pyramided position collapse onto the same
1242
+ deterministic coid, the venue dedups the second create, and half the
1243
+ position is left unprotected.
1244
+ """
1245
+ from_entry = getattr(self.intent, 'from_entry', None)
1246
+ if from_entry is not None:
1247
+ return f"{self.intent.pine_id}{INTENT_KEY_SEP}{from_entry}"
1248
+ return self.intent.pine_id
1249
+
1250
+
1251
+ # === Compile-time detected script requirements ===
1252
+
1253
+ @dataclass
1254
+ class ScriptRequirements:
1255
+ """Broker capabilities needed by this script. Detected via AST analysis."""
1256
+ market_orders: bool = False
1257
+ limit_orders: bool = False
1258
+ stop_orders: bool = False
1259
+ tp_sl_bracket: bool = False # strategy.exit() with BOTH limit+stop or profit+loss
1260
+ trailing_stop: bool = False
1261
+ strategy_order: bool = False # strategy.order() — no pyramiding limit
1262
+ # True if the script calls any of ``strategy.exit`` / ``strategy.close`` /
1263
+ # ``strategy.close_all``. Every such call requires the exchange to honour
1264
+ # reduce-only semantics — a manual position close otherwise lets the
1265
+ # still-pending exit flip the book the other way. The validator turns
1266
+ # this into a hard reject when ``caps.reduce_only=False``.
1267
+ exit_orders: bool = False
1268
+ # True if the script calls ``strategy.exit(qty=N, from_entry="L", ...)``
1269
+ # with ``N < total qty entered under "L"`` AND includes any bracket-leg
1270
+ # parameters (``limit=``/``stop=``/``profit=``/``loss=``/``trail_*=``).
1271
+ # The validator rejects the script at startup when
1272
+ # ``caps.partial_qty_bracket_exit=False`` — position-attribute bracket
1273
+ # exchanges (Capital.com) can only attach bracket to the whole row, not
1274
+ # a partial quantity, and silently covering the full qty would be a
1275
+ # safety violation.
1276
+ partial_qty_bracket_exit: bool = False
1277
+ # True if any ``strategy.entry`` / ``strategy.order`` call passes a
1278
+ # syntactically constant ``strategy.short`` direction (raw, ``lib.``-
1279
+ # normalized, or ``strategy.direction.short`` spelled out). The validator
1280
+ # rejects the script at startup when ``caps.short_selling`` is
1281
+ # UNSUPPORTED. A dynamic direction (variable, conditional expression)
1282
+ # cannot be proven at compile time and leaves the flag ``False`` — the
1283
+ # detection is advisory only; the sync engine's projected-position gate
1284
+ # is the authoritative runtime guard on short-incapable venues.
1285
+ may_go_short: bool = False
1286
+
1287
+
1288
+ # === Interceptor (Order Sync Engine extension point) ===
1289
+
1290
+ # === Broker events (observability) =======================================
1291
+
1292
+ @dataclass
1293
+ class BrokerEvent:
1294
+ """Base class for structured broker-side events.
1295
+
1296
+ The plugin emits these via an injected callback so the runner can
1297
+ surface them in logs, metrics, and the user-facing event stream
1298
+ without the plugin coupling to any specific sink.
1299
+ """
1300
+
1301
+
1302
+ @dataclass
1303
+ class AuthenticationFailedEvent(BrokerEvent):
1304
+ """Emitted when the plugin's credentials are rejected by the exchange.
1305
+
1306
+ ``reason`` is the short human-readable cause (``AuthenticationError.reason``);
1307
+ the runner surfaces the event to observability sinks and then performs a
1308
+ graceful stop — reconnect cannot gain access with wrong credentials.
1309
+ """
1310
+ reason: str
1311
+
1312
+
1313
+ @dataclass
1314
+ class BracketRegisteredEvent(BrokerEvent):
1315
+ pine_id: str
1316
+ from_entry: str
1317
+ tp_order_id: str | None
1318
+ sl_order_id: str | None
1319
+
1320
+
1321
+ @dataclass
1322
+ class LegPartialRepairedEvent(BrokerEvent):
1323
+ pine_id: str
1324
+ from_entry: str
1325
+ leg: str # "tp" | "sl"
1326
+ generation: int
1327
+ old_qty: float
1328
+ new_qty: float
1329
+
1330
+
1331
+ @dataclass
1332
+ class LegRepairFailedEvent(BrokerEvent):
1333
+ pine_id: str
1334
+ from_entry: str
1335
+ leg: str # "tp" | "sl"
1336
+ reason: str
1337
+ action_taken: str # "degraded" | "retry" | ...
1338
+
1339
+
1340
+ @dataclass
1341
+ class BracketReconstructedEvent(BrokerEvent):
1342
+ pine_id: str
1343
+ from_entry: str
1344
+ source: str # "open_orders" | "position_snapshot" | ...
1345
+
1346
+
1347
+ @dataclass
1348
+ class ProtectionDegradedEvent(BrokerEvent):
1349
+ """The bracket can no longer be maintained with OCA reduce semantics.
1350
+
1351
+ ``reason`` is human-readable / diagnostic; ``policy_action`` names the
1352
+ manager's chosen follow-up (``"degraded"`` → bracket left in place but
1353
+ unsupervised; ``"terminal"`` → bracket closed out).
1354
+ """
1355
+ pine_id: str
1356
+ from_entry: str
1357
+ reason: str
1358
+ policy_action: str
1359
+
1360
+
1361
+ @dataclass
1362
+ class ManualInterventionRequiredEvent(BrokerEvent):
1363
+ """Emitted when the sync engine halts because the plugin raised
1364
+ :class:`~pynecore.core.broker.exceptions.BrokerManualInterventionError`.
1365
+
1366
+ Surfaces the operator-actionable details (reason, optional intent key,
1367
+ plugin-supplied diagnostic context) so the observability bus, on-call
1368
+ alerting, and the user-facing event stream can page without the runner
1369
+ needing to reach into plugin internals. After this event fires the
1370
+ engine is halted — all subsequent :meth:`sync` calls return early until
1371
+ the strategy is restarted.
1372
+ """
1373
+ reason: str
1374
+ intent_key: str | None = None
1375
+ context: dict | None = None
1376
+
1377
+
1378
+ @dataclass
1379
+ class QuarantineEnteredEvent(BrokerEvent):
1380
+ """Emitted once when the sync engine latches its quarantine state.
1381
+
1382
+ Quarantine stops trading without stopping the bot: new and
1383
+ exposure-increasing dispatches (entry orders, entry amends) are
1384
+ blocked, while event ingestion, protective exits, cancels and closes
1385
+ keep working — the process stays a live observer of its open
1386
+ exposure. The operator resolves the underlying cause and restarts the
1387
+ strategy; the engine never leaves quarantine on its own.
1388
+ """
1389
+ reason: str
1390
+ intent_key: str | None = None
1391
+ context: dict | None = None
1392
+
1393
+
1394
+ @dataclass
1395
+ class NativeFailsafeStateTransitionEvent(BrokerEvent):
1396
+ """The §2.6.7 ``NativeStopState`` for ``parent_entry_dispatch_ref`` changed
1397
+ health state.
1398
+
1399
+ Emitted on every ``healthy ↔ degrading ↔ degraded ↔ retired`` transition so
1400
+ observability sinks see the full lifecycle, not only terminal failures.
1401
+ """
1402
+ parent_entry_dispatch_ref: str
1403
+ symbol: str
1404
+ from_state: str # 'healthy' | 'degrading' | 'degraded' | 'retired'
1405
+ to_state: str
1406
+ reason: str
1407
+
1408
+
1409
+ @dataclass
1410
+ class PartialBracketBlockedDegradedFailsafeEvent(BrokerEvent):
1411
+ """A new SOFTWARE partial-qty bracket dispatch was rejected because
1412
+ the parent's ``NativeStopState`` is ``degrading`` / ``degraded``.
1413
+
1414
+ The engine still services *close* and *intermediate-leg* dispatches for the
1415
+ same parent — only *new* partial brackets are blocked while the broker-native
1416
+ fail-safe cannot be verified present.
1417
+ """
1418
+ parent_entry_dispatch_ref: str
1419
+ symbol: str
1420
+ pine_id: str
1421
+ from_entry: str
1422
+ health: str # 'degrading' | 'degraded'
1423
+
1424
+
1425
+ @dataclass
1426
+ class EntryBlockedDegradedFailsafeEvent(BrokerEvent):
1427
+ """A new ``strategy.entry`` on a symbol that has at least one
1428
+ ``degrading`` / ``degraded`` ``NativeStopState`` was rejected (§2.6.7).
1429
+
1430
+ The script's signal is dropped rather than queued — see
1431
+ :class:`EntrySkippedDueToDegradedFailsafeEvent` for the replay-policy
1432
+ consequence.
1433
+ """
1434
+ symbol: str
1435
+ pine_id: str
1436
+ health: str # 'degrading' | 'degraded'
1437
+
1438
+
1439
+ @dataclass
1440
+ class EntrySkippedDueToDegradedFailsafeEvent(BrokerEvent):
1441
+ """Drop-semantics counterpart of
1442
+ :class:`EntryBlockedDegradedFailsafeEvent`: the entry signal is NOT
1443
+ queued and will NOT be replayed when the failsafe recovers (§2.6.7).
1444
+ """
1445
+ symbol: str
1446
+ pine_id: str
1447
+ bar_ts_ms: int
1448
+
1449
+
1450
+ @dataclass
1451
+ class BrokerNativeFailsafeUnavailableEvent(BrokerEvent):
1452
+ """The native fail-safe stop on ``parent_entry_dispatch_ref`` has dropped
1453
+ to ``degraded`` — the PUT retry budget is exhausted or the stale window
1454
+ expired (§2.6). Until a user reset / ``set_risk`` arrives, the engine
1455
+ will not re-issue native bracket amends for this parent.
1456
+ """
1457
+ parent_entry_dispatch_ref: str
1458
+ symbol: str
1459
+ reason: str
1460
+
1461
+
1462
+ @dataclass
1463
+ class BrokerNativeFailsafeExternalEditEvent(BrokerEvent):
1464
+ """Snapshot diff detected ``actual_level != desired_level`` with no
1465
+ pending PUT in flight (§2.6.7). Owner flipped to ``unknown`` — the
1466
+ engine will not overwrite until the user explicitly resets ownership.
1467
+ """
1468
+ parent_entry_dispatch_ref: str
1469
+ symbol: str
1470
+ desired_level: float | None
1471
+ actual_level: float | None
1472
+
1473
+
1474
+ @dataclass
1475
+ class BrokerNativeFailsafeFullCloseEvent(BrokerEvent):
1476
+ """The broker-native worst-SL stop on the parent fired and closed the
1477
+ full residual (§3.4 cascade). Every remaining engine-trigger partial
1478
+ leg under this parent is cascaded into ``cascaded_cancel_by_native_sl``
1479
+ (snapshot-driven, idempotent on ``dealId + generation``).
1480
+ """
1481
+ parent_entry_dispatch_ref: str
1482
+ symbol: str
1483
+ actual_level: float | None
1484
+
1485
+
1486
+ class CancelDispositionOutcome(StrEnum):
1487
+ """Normalized outcome of a broker-side cancel attempt.
1488
+
1489
+ Returned by :meth:`BrokerPlugin.execute_cancel_with_outcome` and used by
1490
+ the sync engine's ``reconcile()`` cancel-retry-loop to drive the
1491
+ cancel-tentative state machine forward. The five outcomes encode the
1492
+ only decision-relevant disposition categories — broker-specific status
1493
+ strings, HTTP codes, and exception types are normalized into these by
1494
+ each plugin's override.
1495
+
1496
+ See the cancel-tentative state design dossier §2.6 for the resolution
1497
+ table that maps each outcome to a leg state transition.
1498
+ """
1499
+ # Broker explicitly confirmed the cancel landed (live order →
1500
+ # cancelled, no fill). Sync engine: confirm-cancel-tentative,
1501
+ # flip legs to ``aborted_parent_never_arrived``, drop mapping.
1502
+ CANCEL_CONFIRMED = 'cancel_confirmed'
1503
+ # Broker explicitly reported that the order had already filled
1504
+ # before / during the cancel attempt (race lost). Sync engine:
1505
+ # restore legs from ``cancel_tentative``, re-register parent.
1506
+ ALREADY_FILLED = 'already_filled'
1507
+ # Broker explicitly reported that the order can no longer be
1508
+ # cancelled (e.g., past its execution window) AND that no fill
1509
+ # occurred. Treated as ``CANCEL_CONFIRMED`` by the sync engine —
1510
+ # the disposition is unambiguous, only the wording differs.
1511
+ TOO_LATE_TO_CANCEL = 'too_late_to_cancel'
1512
+ # A fresh ``execute_cancel`` round succeeded (i.e., the order was
1513
+ # in fact still live and is now cancelled — distinct from
1514
+ # CANCEL_CONFIRMED in that the *previous* attempt's disposition
1515
+ # was unknown; this one is unambiguous). Treated as
1516
+ # ``CANCEL_CONFIRMED`` by the sync engine.
1517
+ STILL_OPEN = 'still_open'
1518
+ # Plugin could not disambiguate (404 / not-found / timeout /
1519
+ # ambiguous response). Sync engine: leg stays
1520
+ # ``cancel_tentative``, retry on next reconcile until stale-grace.
1521
+ UNKNOWN = 'unknown'
1522
+
1523
+
1524
+ @dataclass
1525
+ class PartialBracketCancelTentativeStartedEvent(BrokerEvent):
1526
+ """A pending-entry partial bracket leg entered ``cancel_tentative``
1527
+ because :meth:`BrokerPlugin.execute_cancel` raised
1528
+ :class:`OrderDispositionUnknownError`.
1529
+
1530
+ The sync engine retains the order mapping and envelope; the
1531
+ ``reconcile()`` cancel-retry-loop drives the leg to resolution within
1532
+ the stale-grace window. See the cancel-tentative state design dossier.
1533
+ """
1534
+ intent_key: str
1535
+ reason: str
1536
+ since_ts_ms: int
1537
+
1538
+
1539
+ @dataclass
1540
+ class PartialBracketCancelTentativeResolvedEvent(BrokerEvent):
1541
+ """A ``cancel_tentative`` leg has been resolved — either confirmed
1542
+ cancelled (leg ``aborted_parent_never_arrived``) or restored (leg
1543
+ back to ``pending_entry`` / ``armed``) following a broker disposition
1544
+ outcome or a late parent fill event.
1545
+
1546
+ ``outcome`` reflects the resolution reason; ``via`` indicates which
1547
+ code path delivered it (``reconcile_retry`` or ``order_event``).
1548
+ """
1549
+ intent_key: str
1550
+ outcome: CancelDispositionOutcome
1551
+ via: str # 'reconcile_retry' | 'order_event'
1552
+ duration_ms: int
1553
+
1554
+
1555
+ @dataclass
1556
+ class PartialBracketCancelTentativeDegradedEvent(BrokerEvent):
1557
+ """The stale-grace deadline (default 10s) for a ``cancel_tentative``
1558
+ intent expired without resolution.
1559
+
1560
+ The parent dispatch is promoted to ``DEGRADED_HALT``; the script's
1561
+ further entries on the symbol are blocked until manual intervention
1562
+ via :class:`ManualInterventionRequiredEvent`.
1563
+ """
1564
+ intent_key: str
1565
+ symbol: str
1566
+ since_ts_ms: int
1567
+ stale_grace_ms: int
1568
+
1569
+
1570
+ @dataclass
1571
+ class EntryDeferredCancelDispositionPendingEvent(BrokerEvent):
1572
+ """The diff-loop's adoption path saw a fresh ``EntryIntent`` whose
1573
+ ``intent_key`` is currently in ``cancel_disposition_pending``.
1574
+
1575
+ The new intent is NOT dispatched and NOT adopted — the prior
1576
+ dispatch's disposition is still unresolved, and an in-flight broker
1577
+ state would create a double-life. The script's signal will retry on
1578
+ the next ``sync()`` once the cancel disposition is resolved or
1579
+ promoted to ``DEGRADED_HALT``.
1580
+ """
1581
+ intent_key: str
1582
+ pine_id: str
1583
+ symbol: str
1584
+ since_ts_ms: int
1585
+
1586
+
1587
+ @dataclass
1588
+ class InterceptorResult:
1589
+ """
1590
+ Interceptor decision on an intent — modifiable before execution.
1591
+
1592
+ Intent objects are frozen, so modifications are expressed as override
1593
+ fields on this result rather than by mutating the intent in place.
1594
+ """
1595
+ intent: EntryIntent | ExitIntent | CloseIntent | CancelIntent
1596
+ rejected: bool = False
1597
+ reject_reason: str = ""
1598
+ modified_qty: float | None = None
1599
+ modified_limit: float | None = None
1600
+ modified_stop: float | None = None