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,781 @@
1
+ """
2
+ :class:`BrokerPlugin` — high-level order execution layer.
3
+
4
+ A broker plugin receives Pine Script *intents* (entry, exit bracket, close,
5
+ cancel) and translates them to exchange-specific orders. The plugin author
6
+ decides HOW: native brackets, separate orders with software monitoring,
7
+ ``reduce_only`` flags, editOrder vs cancel-and-replace, etc.
8
+
9
+ Intents carry full Pine Script identity (``pine_id``, ``from_entry``,
10
+ ``oca_name``) so the plugin can track order lifecycle and the sync engine
11
+ can route :class:`OrderEvent` fills back to the correct Pine trade.
12
+
13
+ See ``docs/pynecore/plugin-system/broker-plugin-plan.md`` for the full
14
+ design, in particular the rationale for the high-level intent API.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from abc import ABC, abstractmethod
19
+ from collections.abc import AsyncIterator, Callable
20
+ from typing import TYPE_CHECKING, Protocol, TypeVar
21
+
22
+ from pynecore.core.plugin.live_provider import LiveProviderConfig, LiveProviderPlugin
23
+ from pynecore.core.broker.exceptions import (
24
+ BrokerError,
25
+ ExchangeCapabilityError,
26
+ ExchangeConnectionError,
27
+ OrderDispositionUnknownError,
28
+ )
29
+ from pynecore.core.broker.idempotency import CLIENT_ORDER_ID_MAX_LEN
30
+ from pynecore.core.broker.models import (
31
+ CancelDispositionOutcome,
32
+ CancelIntent,
33
+ DispatchEnvelope,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from pynecore.core.broker.spot_inventory import SpotInventoryPort
38
+ from pynecore.core.broker.storage import RunContext
39
+ from pynecore.core.broker.models import (
40
+ BracketAttachRejectContext,
41
+ ExchangeOrder,
42
+ ExchangePosition,
43
+ ExchangeCapabilities,
44
+ OrderEvent,
45
+ PositionLeg,
46
+ )
47
+
48
+ __all__ = ['BrokerPlugin', 'PositionPort']
49
+
50
+
51
+ class PositionPort(Protocol):
52
+ """Thin transport a one-way-emulating broker plugin exposes to core.
53
+
54
+ A hedging-mode account holds several broker positions ("legs") per symbol;
55
+ Pine sees a single *one-way* position. A plugin that opts into core one-way
56
+ emulation sets :attr:`BrokerPlugin.position_port` to an object (usually
57
+ ``self``) implementing these primitives. The core
58
+ :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` owns all
59
+ netting / FIFO / crash-replay logic and drives the plugin purely through
60
+ this surface — each method sends or reads exactly ONE broker entity.
61
+ Plugins that do not emulate (netting-native, or hedging-rejecting) leave
62
+ ``position_port`` ``None`` and the engine uses the regular ``execute_*``
63
+ path unchanged.
64
+
65
+ The surface grows per emulation feature (close, then reversal, then bracket
66
+ replication); only the methods a wired feature needs are required.
67
+
68
+ Optional capability attribute — read via ``getattr``, absence means
69
+ ``True``:
70
+
71
+ * ``supports_partial_leg_close`` — ``False`` when the venue cannot reduce
72
+ a single leg by a partial volume (e.g. Capital.com, whose position
73
+ DELETE is full-row only). The emulator then pre-flights every close /
74
+ reversal plan and atomically skips
75
+ (:class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`) any
76
+ plan containing a partial leg slice, before persisting or dispatching
77
+ anything.
78
+ """
79
+
80
+ async def fetch_raw_positions(self, symbol: str) -> list[PositionLeg]:
81
+ """All open legs for ``symbol`` (any direction), oldest first. No
82
+ aggregation — the core emulator nets them."""
83
+ ...
84
+
85
+ async def get_volume_quantizer(self, symbol: str) -> Callable[[float], int]:
86
+ """A sync ``Pine-units -> broker-grid-int`` quantizer for ``symbol``.
87
+
88
+ Returned as a closure so the emulator can snap per-leg volumes in a
89
+ tight loop without an await each call; the plugin owns the broker unit
90
+ (e.g. cTrader centi-units snapped to ``stepVolume``).
91
+ """
92
+ ...
93
+
94
+ async def close_leg(
95
+ self, symbol: str, leg_id: str, volume: int, coid: str,
96
+ ) -> None:
97
+ """Send ONE reduce/close of ``volume`` (broker-grid units) on broker
98
+ leg ``leg_id`` under client-order-id ``coid``. The resulting fill
99
+ arrives on the regular order-event stream; this just dispatches."""
100
+ ...
101
+
102
+ async def reject_out_of_range(
103
+ self, envelope: 'DispatchEnvelope', qty: float,
104
+ ) -> None:
105
+ """Raise the broker's non-halting volume-bounds skip
106
+ (:class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`) when
107
+ ``qty`` (Pine units) is below the minimum or above the maximum tradable
108
+ size; return ``None`` when it is in range.
109
+
110
+ The core emulator calls this BEFORE a reversal's leg closes so an
111
+ out-of-range residual skips the whole reversal while that is still true,
112
+ never leaving the book half-reduced.
113
+ """
114
+ ...
115
+
116
+ async def place_leg(
117
+ self, envelope: 'DispatchEnvelope', qty: float,
118
+ ) -> list[ExchangeOrder]:
119
+ """Open ONE order of ``qty`` (Pine units) for the envelope's
120
+ :class:`EntryIntent` — the residual leg of a reversal, or a plain add.
121
+ Returns the resulting :class:`ExchangeOrder`(s)."""
122
+ ...
123
+
124
+ async def amend_bracket(
125
+ self, symbol: str, leg_id: str, *,
126
+ side: str,
127
+ tp_price: float | None,
128
+ sl_price: float | None,
129
+ trail_offset: float | None,
130
+ coid: str,
131
+ ) -> None:
132
+ """Replicate (or clear) a protective bracket on ONE broker leg.
133
+
134
+ Sets the take-profit / stop-loss / trailing levels of the exit's bracket
135
+ on broker leg ``leg_id`` under client-order-id ``coid``. Levels are in
136
+ PINE UNITS (absolute prices for ``tp_price`` / ``sl_price``, a price
137
+ distance for ``trail_offset``), exactly as the :class:`ExitIntent`
138
+ carries them — the plugin owns the conversion to its broker grid and any
139
+ broker-specific reduction (e.g. cTrader has no numeric trailing offset,
140
+ so the plugin seeds an absolute trailing anchor and a trailing flag).
141
+ ``side`` is the exit side, supplied because a trail-only bracket needs it
142
+ to place the initial anchor on the correct side.
143
+
144
+ Passing ``tp_price`` / ``sl_price`` / ``trail_offset`` all ``None``
145
+ CLEARS the bracket on that one leg: on venues like cTrader the protection
146
+ is a single position attribute that an amend overwrites wholesale, so an
147
+ empty amend wipes it. The core emulator drives this per leg from its
148
+ ownership index, so a clear touches only the legs the cancelled exit owns
149
+ (never the whole position side).
150
+
151
+ Must be idempotent on ``coid`` (a restart re-amend with the same levels
152
+ is a broker no-op) and must NOT halt the bot on a leg-already-gone race —
153
+ a ``*_NOT_FOUND`` response normalises to a benign return. A genuine
154
+ rejection propagates as
155
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError` for
156
+ the caller to surface.
157
+ """
158
+ ...
159
+
160
+
161
+ BrokerConfigT = TypeVar('BrokerConfigT', bound=LiveProviderConfig)
162
+
163
+
164
+ class BrokerPlugin(LiveProviderPlugin[BrokerConfigT], ABC):
165
+ """
166
+ High-level order execution layer.
167
+
168
+ Subclasses implement the ``execute_*`` methods in whatever way their
169
+ exchange supports. The Order Sync Engine only calls these methods and
170
+ routes back the :class:`OrderEvent` objects they produce — it never
171
+ reaches into exchange-specific APIs itself.
172
+
173
+ **Bot-owned-order disappearance detection is a plugin responsibility.**
174
+ The engine deliberately does not diff its in-memory order mapping
175
+ against :meth:`get_open_orders`, because the resource namespaces are
176
+ broker-specific: a Pine entry may live as a working order, an open
177
+ position, or a position-attached bracket on different exchanges, and
178
+ ``get_open_orders`` only covers one of those namespaces. The plugin must
179
+ therefore detect manual closes / broker-side liquidations / silent
180
+ cancels itself (typically a per-poll snapshot of *all* relevant
181
+ namespaces, with a small grace window to absorb in-flight races) and
182
+ report disappearance through :meth:`watch_orders` — either by emitting
183
+ a synthesised ``cancelled`` :class:`OrderEvent` (the engine's event
184
+ router cleans it out of internal tracking) or by raising
185
+ :class:`~pynecore.core.broker.exceptions.UnexpectedCancelError`
186
+ according to the configured :attr:`on_unexpected_cancel` policy. See
187
+ the Capital.com plugin's ``_reconcile_snapshot`` +
188
+ ``_emit_unexpected_cancellations`` for the reference implementation.
189
+
190
+ **Transient-fault contract.** A plugin classifies connectivity faults into
191
+ the broker taxonomy: :class:`~pynecore.core.broker.exceptions.ExchangeConnectionError`
192
+ for a drop the engine recovers from by reconnecting, and
193
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError` for a
194
+ write whose acknowledgement was lost in flight (the engine parks it and
195
+ matches against :meth:`get_open_orders`). The Order Sync Engine adds a central
196
+ safety net for a plugin that lets a *raw* transient escape — a retryable
197
+ :class:`~pynecore.core.plugin.ProviderError` (transient wire faults should
198
+ subclass it) or a stdlib :class:`ConnectionError` / :class:`TimeoutError`. The
199
+ net behaves differently on a read than on a write:
200
+
201
+ - On a per-bar state read (:meth:`get_position` / :meth:`get_open_orders`)
202
+ the untranslated transient — retryable ``ProviderError``,
203
+ ``ConnectionError`` or ``TimeoutError`` — is treated as
204
+ ``ExchangeConnectionError`` and parked: reads are idempotent, so the
205
+ retry-next-bar is always safe. The one-shot startup :meth:`get_balance`
206
+ auth probe is outside this net (it runs before the per-bar engine loop, so
207
+ there is no cycle to park onto); a raw transient there aborts startup.
208
+ - On a direct order write (``execute_*`` / ``modify_*`` /
209
+ :meth:`cancel_broker_order_ref`) it is unrecoverable centrally — the engine
210
+ cannot tell a never-sent request from a landed one — so an untranslated
211
+ retryable ``ProviderError`` or ``ConnectionError`` raises
212
+ :class:`~pynecore.core.broker.exceptions.BrokerManualInterventionError` (a
213
+ controlled halt) rather than risk a duplicate fill. A raw write-side
214
+ ``TimeoutError`` is the exception: the dispatch bridge cannot cancel the
215
+ still-running coroutine, so the order may yet land and the timeout stays
216
+ fatal rather than being latched as a halt.
217
+
218
+ The net only catches contract violations; a plugin SHOULD still classify
219
+ explicitly, which keeps the recoverable read path parking and the ambiguous
220
+ write path parked-for-verification instead of halting.
221
+ """
222
+
223
+ client_order_id_max_len: int = CLIENT_ORDER_ID_MAX_LEN
224
+ """
225
+ The venue's client-order-id length budget, in characters.
226
+
227
+ The default (30) is the canonical
228
+ :mod:`~pynecore.core.broker.idempotency` width and fits every currently
229
+ supported exchange. A plugin for a venue with a shorter client-id field
230
+ (some FIX implementations cap ``ClOrdID`` at 20) overrides this with the
231
+ venue's actual limit; the engine then mints deterministic wire-form ids
232
+ of exactly this length (see the idempotency module docstring). Must be
233
+ at least ``WIRE_CLIENT_ORDER_ID_MIN_LEN`` (20) — the startup contract
234
+ probe (:func:`~pynecore.core.broker.validation.validate_plugin_contract`)
235
+ rejects anything lower.
236
+ """
237
+
238
+ defensive_close_resolution_grace_s: float | None = None
239
+ """
240
+ Grace window (seconds) for a defensive close FILL to settle after
241
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._handle_bracket_attach_after_fill_reject`
242
+ dispatches it. The engine halts the run when a pending marker
243
+ survives this window — the missing FILL means an operator must
244
+ intervene.
245
+
246
+ ``None`` (default) uses the engine's built-in default
247
+ (``DEFENSIVE_CLOSE_RESOLUTION_GRACE_S``, currently 30 seconds).
248
+ Plugins on slow venues with multi-minute post-trade reporting
249
+ latency may override.
250
+ """
251
+
252
+ on_unexpected_cancel: str = "stop"
253
+ """
254
+ Policy for bot-owned orders that disappear without the bot cancelling them.
255
+
256
+ One of ``"stop"`` (quarantine: trading stops, the process stays
257
+ alive as a live observer — default), ``"stop_and_cancel"``
258
+ (quarantine + cancel remaining bot orders), ``"re_place"``
259
+ (auto-replace protective orders), ``"ignore"`` (continue), or
260
+ ``"halt"`` (exit the process via the graceful manual-intervention
261
+ path). The class-level default is the quarantining fallback used by
262
+ test paths that construct plugins without the CLI. In production
263
+ the value comes from the cross-broker ``workdir/config/brokers.toml``
264
+ via :class:`~pynecore.core.broker.defaults.BrokerDefaults` — the
265
+ ``pyne run --broker`` entry point loads it once and assigns it as
266
+ an instance attribute on the plugin before the script runner
267
+ starts. The policy is broker-agnostic by design, so plugins do
268
+ not declare it in their own ``Config`` dataclasses.
269
+ """
270
+
271
+ _account_id: str | None = None
272
+ """Plugin-qualified broker account identifier.
273
+
274
+ Subclasses populate this during the authentication flow
275
+ (``connect()`` / ``create_session()``), e.g.
276
+ ``self._account_id = f"capitalcom-demo-{preferred_account}"``. The
277
+ :meth:`account_id` property reads from here and falls back to
278
+ ``"default"`` when the plugin has not been authenticated — keeps tests
279
+ and backtest-only paths working without mandating an identity.
280
+
281
+ The value is used by the :class:`~pynecore.core.broker.run_identity.RunIdentity`
282
+ to derive the ``run_id`` and ``run_tag``. It is fixed at run-creation
283
+ time — if the user switches accounts at the broker UI mid-run, the bot
284
+ must be restarted so a new ``run_instance_id`` is allocated. That is
285
+ the intended safety boundary, not a limitation.
286
+ """
287
+
288
+ @property
289
+ def account_id(self) -> str:
290
+ """Plugin-qualified broker account identifier.
291
+
292
+ Sync property (not async) by design: the identity must be fixed
293
+ before the broker storage opens a run, and the storage layer is
294
+ sync. Authentication (which **is** network I/O) populates
295
+ ``self._account_id`` once; the property reads it back without
296
+ making any further calls.
297
+
298
+ :return: The populated identifier, or ``"default"`` when the plugin
299
+ has not yet authenticated. Returning a sentinel rather than
300
+ raising keeps test paths (mock brokers, backtests) simple.
301
+ """
302
+ return self._account_id or "default"
303
+
304
+ store_ctx: 'RunContext | None' = None
305
+ """Optional per-run :class:`RunContext` from the unified broker storage.
306
+
307
+ The :class:`~pynecore.core.script_runner.ScriptRunner` sets this on the
308
+ plugin after :meth:`~pynecore.core.broker.storage.BrokerStore.open_run`
309
+ returns, so plugin code can perform ``add_ref`` / ``find_by_ref`` /
310
+ ``upsert_order`` / ``log_event`` calls without receiving the context
311
+ as a parameter on every broker API. ``None`` during test paths or
312
+ when persistence is off; plugin methods should guard accordingly.
313
+ """
314
+
315
+ native_failsafe_observed_sink: Callable[..., None] | None = None
316
+ """Optional §2.6.7 broker-native fail-safe recovery feed.
317
+
318
+ The :class:`~pynecore.core.script_runner.ScriptRunner` installs a closure
319
+ that routes one broker-observed bracket triple into the Order Sync Engine's
320
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.record_native_bracket_observed`
321
+ (it supplies the engine's bar-clock ``now_ms``, so every fail-safe timestamp
322
+ stays on one clock). A plugin's reconcile pass calls this once per live
323
+ position so a parent stuck in ``DEGRADING`` (restart-replayed, or after a
324
+ PUT retry whose success report could not be confirmed directly) flips back
325
+ to ``HEALTHY`` once the broker is observed carrying the desired worst-SL.
326
+ Without the feed the stale-window timer escalates ``DEGRADING -> DEGRADED``,
327
+ blocking new entries / partial brackets until a manual ``reset_to_engine``.
328
+
329
+ The callee is keyed by ``parent_entry_dispatch_ref`` (the entry order's
330
+ ``client_order_id``) and the engine no-ops for refs it does not track, so
331
+ the plugin may feed every live position blindly without consulting the
332
+ fail-safe state. ``None`` when persistence is off or the runner has not
333
+ wired it (state-only test paths); the plugin must guard accordingly.
334
+
335
+ Signature: ``sink(parent_entry_dispatch_ref, *, stop_level, profit_level,
336
+ trailing_stop)`` — pass ``None`` for fields the broker is not carrying.
337
+ """
338
+
339
+ quarantine_sink: Callable[..., None] | None = None
340
+ """Optional quarantine latch into the Order Sync Engine.
341
+
342
+ The :class:`~pynecore.core.script_runner.ScriptRunner` wires this to
343
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.record_quarantine`.
344
+ The plugin's disappearance tracking (typically a
345
+ :class:`~pynecore.core.broker.disappearance.DisappearanceTracker`
346
+ constructed with this as its ``request_quarantine`` hook) calls it when
347
+ the ``stop`` / ``stop_and_cancel`` policy fires: trading stops (the
348
+ engine blocks new entry dispatch and entry amends) while the process —
349
+ including the plugin's own event stream — keeps running, so the bot
350
+ stays a live observer of its open exposure instead of dying on a live
351
+ market. ``None`` when the runner has not wired it (state-only test
352
+ paths); the tracker then falls back to the process-exiting halt, which
353
+ is fail-safe, never fail-open.
354
+
355
+ Signature: ``sink(reason: str, context: dict | None = None)``.
356
+ """
357
+
358
+ native_cancel_all_expected_sink: Callable[..., None] | None = None
359
+ """Optional expected-cancel registration for a native bulk cancel.
360
+
361
+ The :class:`~pynecore.core.script_runner.ScriptRunner` wires this to
362
+ :meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.enqueue_native_cancel_all_expected`.
363
+ A plugin that implements :meth:`execute_cancel_all` with a single native
364
+ bulk-cancel endpoint (e.g. Bybit ``POST /v5/order/cancel-all``) bypasses
365
+ the engine's per-order :class:`~pynecore.core.broker.models.CancelIntent`
366
+ dispatch, so the subsequent broker-pushed ``CANCELLED`` events for the
367
+ orders it removed would otherwise be misread as EXTERNAL cancels and trip
368
+ the :attr:`on_unexpected_cancel` quarantine. The plugin MUST call this
369
+ sink BEFORE issuing the venue bulk-cancel so the engine registers every
370
+ currently-mapped order id as expected-to-cancel; the marker is FIFO-ordered
371
+ ahead of the ``CANCELLED`` pushes it precedes. ``None`` when the runner has
372
+ not wired it (state-only test paths); the plugin must guard accordingly.
373
+
374
+ Signature: ``sink(symbol: str | None = None)``.
375
+ """
376
+
377
+ on_inventory_conflict: str = "quarantine"
378
+ """
379
+ Policy for a confirmed spot balance-invariant conflict.
380
+
381
+ Only meaningful for plugins that opt into the core spot inventory
382
+ layer (:attr:`spot_inventory_port`). ``"quarantine"`` (default)
383
+ latches the engine's quarantine — trading stops, the process stays
384
+ alive as an observer; ``"halt"`` exits via the graceful
385
+ manual-intervention path. Deliberately narrower than
386
+ :attr:`on_unexpected_cancel`: ``re_place`` would buy back an
387
+ operator's withdrawal and ``ignore`` would trade on corrupt books,
388
+ so neither applies to an attribution conflict. In production the
389
+ value comes from ``workdir/config/brokers.toml`` via
390
+ :class:`~pynecore.core.broker.defaults.BrokerDefaults`, injected
391
+ like :attr:`on_unexpected_cancel`.
392
+ """
393
+
394
+ spot_inventory_port: 'SpotInventoryPort | None' = None
395
+ """Optional spot-venue inventory surface.
396
+
397
+ A spot plugin (no venue position object; pooled balances) sets this
398
+ (typically to ``self``) so the core
399
+ :class:`~pynecore.core.broker.spot_inventory.SpotInventoryManager`
400
+ can own the execution ledger, the balance-invariant reconciliation
401
+ and the position synthesis. Stays ``None`` for margin/CFD venues.
402
+ The startup contract probe
403
+ (:func:`~pynecore.core.broker.validation.validate_plugin_contract`)
404
+ verifies the full port surface when set.
405
+ """
406
+
407
+ position_port: 'PositionPort | None' = None
408
+ """Optional one-way emulation transport (hedging-mode accounts).
409
+
410
+ When non-``None``, the Order Sync Engine routes reducing / closing and
411
+ reversing intents for this plugin through the core
412
+ :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` instead of
413
+ the plugin's ``execute_close`` / ``execute_entry``, so the per-leg FIFO
414
+ fan-out and its persist-first crash/replay live in core. The plugin sets
415
+ this (typically to ``self``) once it knows the account is hedging-mode; it
416
+ stays ``None`` for netting accounts and for plugins that do not emulate,
417
+ leaving their existing dispatch path untouched.
418
+ """
419
+
420
+ # === High-level order intents ===
421
+ #
422
+ # Every execute_* method takes a :class:`DispatchEnvelope` rather than a
423
+ # bare intent. The envelope carries the idempotency metadata the plugin
424
+ # needs to allocate stable ``client_order_id`` values via
425
+ # :meth:`DispatchEnvelope.client_order_id`. The wrapped intent is on
426
+ # ``envelope.intent`` with its original Pine-level fields intact.
427
+
428
+ @abstractmethod
429
+ async def execute_entry(self, envelope: 'DispatchEnvelope') -> list['ExchangeOrder']:
430
+ """
431
+ Open or add to a position.
432
+
433
+ Maps to ``strategy.entry()`` and ``strategy.order()``. ``envelope.intent``
434
+ is the :class:`EntryIntent`. Use ``envelope.client_order_id(KIND_ENTRY)``
435
+ for the exchange-side client id.
436
+
437
+ | Pine params | order_type | limit | stop |
438
+ |---------------------|--------------|----------|----------|
439
+ | no limit, no stop | MARKET | None | None |
440
+ | limit only | LIMIT | price | None |
441
+ | stop only | STOP | None | trigger |
442
+
443
+ A both-set Pine entry (``limit`` AND ``stop``) is not a stop-limit
444
+ order — Pine has none. The sync engine splits it into two OCO legs
445
+ before dispatch: the LIMIT leg arrives here as ``order_type=LIMIT``,
446
+ and if the stop side triggers the engine sends a separate
447
+ ``order_type=MARKET`` entry. The plugin therefore never receives a
448
+ single both-set order — only plain MARKET / LIMIT / STOP.
449
+ """
450
+
451
+ @abstractmethod
452
+ async def execute_exit(self, envelope: 'DispatchEnvelope') -> list['ExchangeOrder']:
453
+ """
454
+ Exit (reduce) a position. OCA REDUCE semantics expected.
455
+
456
+ Maps to ``strategy.exit()``. ``envelope.intent`` is the
457
+ :class:`ExitIntent`. Allocate per-leg client ids via
458
+ ``envelope.client_order_id(KIND_EXIT_TP)`` and
459
+ ``envelope.client_order_id(KIND_EXIT_SL)``.
460
+
461
+ The plugin decides HOW to implement the TP+SL bracket on its exchange:
462
+ native bracket orders, separate orders with monitoring, etc.
463
+
464
+ MUST handle: partial fill on one leg adjusts the other. If the
465
+ exchange cannot support a required combination, raise
466
+ :class:`ExchangeCapabilityError`.
467
+ """
468
+
469
+ @abstractmethod
470
+ async def execute_close(self, envelope: 'DispatchEnvelope') -> 'ExchangeOrder':
471
+ """
472
+ Close a position with a market order.
473
+
474
+ Maps to ``strategy.close()`` / ``strategy.close_all()``. Use
475
+ ``envelope.client_order_id(KIND_CLOSE)`` for the exchange-side id.
476
+ """
477
+
478
+ @abstractmethod
479
+ async def execute_cancel(self, envelope: 'DispatchEnvelope') -> bool:
480
+ """
481
+ Cancel pending order(s). Returns ``True`` if cancelled.
482
+
483
+ ``envelope.intent`` is the :class:`CancelIntent`. The canonical cancel
484
+ id (``envelope.client_order_id(KIND_CANCEL)``) is primarily useful for
485
+ audit and retry correlation — the actual exchange call typically
486
+ references the existing order by its exchange-side id.
487
+ """
488
+
489
+ async def execute_cancel_with_outcome(
490
+ self, envelope: 'DispatchEnvelope',
491
+ ) -> CancelDispositionOutcome:
492
+ """
493
+ Cancel pending order(s) and return a normalized disposition outcome.
494
+
495
+ Used by the sync engine's cancel-tentative state machine to drive
496
+ ``reconcile()``'s idempotent cancel-retry loop forward without
497
+ making broker-specific assumptions (e.g., what HTTP 404 means).
498
+ Each plugin override classifies its exchange-side responses into
499
+ the five :class:`CancelDispositionOutcome` categories.
500
+
501
+ The default implementation maps the existing :meth:`execute_cancel`
502
+ contract conservatively to
503
+ :attr:`CancelDispositionOutcome.UNKNOWN`:
504
+
505
+ - ``execute_cancel`` returns ``True`` →
506
+ :attr:`CancelDispositionOutcome.UNKNOWN`
507
+ - ``execute_cancel`` returns ``False`` →
508
+ :attr:`CancelDispositionOutcome.UNKNOWN`
509
+ - ``execute_cancel`` raises
510
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError` →
511
+ :attr:`CancelDispositionOutcome.UNKNOWN`
512
+ - Any other exception propagates.
513
+
514
+ Both boolean outcomes collapse to ``UNKNOWN`` because the bool-only
515
+ :meth:`execute_cancel` contract cannot disambiguate the four terminal
516
+ cancel dispositions safely:
517
+
518
+ - ``True`` can mean a confirmed cancel OR a benign no-op (no live row
519
+ matched: the parent may have already filled, leaving an open
520
+ position the engine must keep tracking). Mapping ``True`` to
521
+ :attr:`CancelDispositionOutcome.CANCEL_CONFIRMED` would let the
522
+ sync engine abort the partial-bracket legs of a freshly filled
523
+ parent — exactly the opposite of the intended safety behaviour.
524
+ - ``False`` means *"cancel did not land"* (still pending), the
525
+ opposite of the ``STILL_OPEN`` enum semantic, which the sync
526
+ engine treats like a confirmed cancel (legs aborted, mapping
527
+ dropped).
528
+
529
+ ``UNKNOWN`` keeps the cancel-tentative armed; the engine retries on
530
+ the next ``reconcile()`` (or resolves via a broker-pushed FILL /
531
+ CANCEL event in the meantime), so progress still happens.
532
+
533
+ ``CANCEL_CONFIRMED``, ``ALREADY_FILLED``, ``TOO_LATE_TO_CANCEL`` and
534
+ ``STILL_OPEN`` outcomes are reachable ONLY via plugin override — the
535
+ default cannot disambiguate them from the bool-only contract.
536
+ Plugins that can read the exchange's post-cancel disposition
537
+ (activity history, position diff) SHOULD override and emit the more
538
+ precise outcome.
539
+
540
+ :param envelope: Dispatch envelope around the
541
+ :class:`CancelIntent`; ``envelope.intent`` is the cancel
542
+ request, ``envelope.client_order_id(KIND_CANCEL)`` is the
543
+ audit-correlation id.
544
+ :return: Normalized cancel disposition outcome.
545
+ """
546
+ try:
547
+ await self.execute_cancel(envelope)
548
+ except OrderDispositionUnknownError:
549
+ return CancelDispositionOutcome.UNKNOWN
550
+ return CancelDispositionOutcome.UNKNOWN
551
+
552
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
553
+ async def execute_cancel_all(self, symbol: str | None = None) -> int:
554
+ """Cancel all open orders. Returns the count cancelled."""
555
+ raise ExchangeCapabilityError("Bulk cancel not supported")
556
+
557
+ # === Defensive-close recovery (bracket attach reject) ===
558
+
559
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
560
+ def get_residual_orders_after_bracket_attach_reject(
561
+ self, context: 'BracketAttachRejectContext',
562
+ ) -> list[str]:
563
+ """Enumerate broker-side orders the engine must cancel as part of
564
+ defensive recovery after a bracket attach reject.
565
+
566
+ Called by the engine's defensive-close path (see
567
+ :class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`
568
+ and the ``defensive-close-pending-lifecycle`` design dossier)
569
+ after the engine has dispatched the defensive close. The returned
570
+ broker-side refs are passed back one-by-one to
571
+ :meth:`cancel_broker_order_ref`.
572
+
573
+ Default implementation returns an empty list. Plugins that
574
+ raise :class:`BracketAttachAfterFillRejectedError` MUST override
575
+ this method when their execution model can leave residual
576
+ cancellable orders, namely:
577
+
578
+ - Residual unfilled portion of a partial-fill parent entry that
579
+ is NOT auto-cancelled by the exchange when the bracket attach
580
+ fails.
581
+ - Separate TP/SL order entities (when the exchange does not use
582
+ position-attached protective levels).
583
+
584
+ Implementations MUST be safe to call repeatedly with the same
585
+ context — startup replay invokes it again so terminal orders may
586
+ legitimately drop out of the list between calls.
587
+
588
+ :param context: Recovery context built from the raised
589
+ :class:`BracketAttachAfterFillRejectedError`.
590
+ :return: List of exchange-native order refs to cancel. Empty by
591
+ default; the engine treats an empty list as "no residual
592
+ cleanup required".
593
+ """
594
+ return []
595
+
596
+ # noinspection PyMethodMayBeStatic,PyUnusedLocal
597
+ async def cancel_broker_order_ref(self, ref: str) -> None:
598
+ """Cancel a broker-side order by its raw exchange ref.
599
+
600
+ Used by engine-internal recovery flows that operate on refs
601
+ outside the Pine intent system — currently the defensive-close
602
+ residual-cancel loop after a
603
+ :class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`.
604
+
605
+ **Idempotency contract** (MUST be honoured by every plugin that
606
+ overrides this method):
607
+
608
+ - "not found" / "already cancelled" / "already filled" responses
609
+ from the exchange MUST be normalized to a successful no-op
610
+ return (NO exception).
611
+ - Transient connectivity failures MUST raise
612
+ :class:`~pynecore.core.broker.exceptions.ExchangeConnectionError`
613
+ or
614
+ :class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
615
+ so the engine can retry on the next reconcile / restart.
616
+ - Any other exchange-side rejection (e.g.
617
+ :class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`)
618
+ MUST propagate — the engine treats it as an operator-attention
619
+ condition and halts.
620
+
621
+ The default implementation raises :class:`NotImplementedError`
622
+ — plugins that return non-empty lists from
623
+ :meth:`get_residual_orders_after_bracket_attach_reject` MUST
624
+ also override this method.
625
+ """
626
+ raise NotImplementedError(
627
+ "cancel_broker_order_ref() must be overridden by plugins that "
628
+ "return non-empty lists from "
629
+ "get_residual_orders_after_bracket_attach_reject()"
630
+ )
631
+
632
+ # === Modify (upsert/replace) ===
633
+
634
+ async def modify_entry(
635
+ self, old: 'DispatchEnvelope', new: 'DispatchEnvelope',
636
+ ) -> list['ExchangeOrder']:
637
+ """
638
+ Modify an existing entry order (price/qty changed).
639
+
640
+ Default implementation: cancel + execute. Plugin authors SHOULD
641
+ override with an atomic amend when the exchange supports it.
642
+ """
643
+ cancel_envelope = DispatchEnvelope(
644
+ intent=CancelIntent(
645
+ pine_id=old.intent.pine_id,
646
+ symbol=old.intent.symbol,
647
+ ),
648
+ run_tag=new.run_tag,
649
+ bar_ts_ms=new.bar_ts_ms,
650
+ retry_seq=new.retry_seq,
651
+ coid_max_len=new.coid_max_len,
652
+ )
653
+ await self.execute_cancel(cancel_envelope)
654
+ return await self.execute_entry(new)
655
+
656
+ async def modify_exit(
657
+ self, old: 'DispatchEnvelope', new: 'DispatchEnvelope',
658
+ ) -> list['ExchangeOrder']:
659
+ """
660
+ Modify an existing exit bracket (TP/SL price changed).
661
+
662
+ Default: cancel + new. This opens a window without protection —
663
+ plugin authors SHOULD override with an atomic amend when the
664
+ exchange supports it (``editOrder``, Bybit amend, etc.).
665
+ """
666
+ old_exit = old.intent
667
+ cancel_envelope = DispatchEnvelope(
668
+ intent=CancelIntent(
669
+ pine_id=old_exit.pine_id,
670
+ symbol=old_exit.symbol,
671
+ from_entry=getattr(old_exit, 'from_entry', None),
672
+ ),
673
+ run_tag=new.run_tag,
674
+ bar_ts_ms=new.bar_ts_ms,
675
+ retry_seq=new.retry_seq,
676
+ coid_max_len=new.coid_max_len,
677
+ )
678
+ await self.execute_cancel(cancel_envelope)
679
+ return await self.execute_exit(new)
680
+
681
+ # === State queries ===
682
+
683
+ @abstractmethod
684
+ async def get_open_orders(self, symbol: str | None = None) -> list['ExchangeOrder']:
685
+ """Fetch all open orders from the exchange."""
686
+
687
+ @abstractmethod
688
+ async def get_position(self, symbol: str) -> 'ExchangePosition | None':
689
+ """Fetch the current position for ``symbol``.
690
+
691
+ ``None`` means "no position exists for this symbol" and the engine
692
+ treats it as an **authoritative flat**: the startup ``reconcile``
693
+ adopts it unconditionally (a restart over ``None`` starts flat), and
694
+ the periodic ``reconcile`` reads a held-position → ``None``
695
+ transition as an external flatten. Returning ``None`` merely because
696
+ the venue has no native position *object* is therefore wrong — after
697
+ a restart the engine would forget the open exposure and double-open
698
+ on the first entry signal.
699
+
700
+ Spot venues MUST synthesize an :class:`ExchangePosition` from the
701
+ plugin's own persisted fill ledger (net base inventory as ``size``,
702
+ ledger VWAP as ``entry_price``, mark-to-market ``unrealized_pnl``,
703
+ ``leverage=1.0``) and may return ``None`` only when the bot's net
704
+ inventory is genuinely flat.
705
+ """
706
+
707
+ @abstractmethod
708
+ async def get_balance(self) -> dict[str, float]:
709
+ """Get available balance per currency."""
710
+
711
+ # === Live order stream ===
712
+
713
+ def watch_orders(self) -> AsyncIterator['OrderEvent']:
714
+ """
715
+ Stream order status updates.
716
+
717
+ If not implemented, the framework polls :meth:`get_open_orders` on
718
+ each bar. Return an async iterator of :class:`OrderEvent` objects;
719
+ the plugin is responsible for filling in the Pine identity fields
720
+ (``pine_id``, ``from_entry``, ``leg_type``) on each event.
721
+
722
+ The stream is the only channel through which the engine learns
723
+ about broker-side state transitions, and the plugin is free to
724
+ emit *synthesised* events alongside any native exchange feed:
725
+
726
+ - A ``cancelled`` event for a bot-owned order the plugin itself
727
+ observed disappearing (e.g. via an internal ``/positions`` +
728
+ ``/workingorders`` poll past a grace window) — the engine's
729
+ event router pops the matching ``_order_mapping`` entry exactly
730
+ as it would for a native cancel.
731
+ - Recovery events that backfill state missed during a network
732
+ drop or restart, before the native stream catches up.
733
+
734
+ Plugins that decide a disappearance warrants a graceful halt
735
+ instead of a soft cancel should raise
736
+ :class:`~pynecore.core.broker.exceptions.UnexpectedCancelError`
737
+ from the same generator — the engine catches it on the broker
738
+ thread and latches a halt flag so the next runner tick exits via
739
+ its ``finally`` block.
740
+ """
741
+ raise NotImplementedError
742
+
743
+ # === Exception mapping ===
744
+
745
+ # noinspection PyMethodMayBeStatic
746
+ def _map_exception(self, raw: Exception) -> BrokerError | None:
747
+ """Translate a raw exchange-SDK exception into the broker taxonomy.
748
+
749
+ Utility hook for plugin authors — **not** called by the sync engine
750
+ directly. Plugin ``execute_*`` implementations wrap their SDK calls in
751
+ try/except and delegate classification here so exchange-specific
752
+ knowledge stays in one place. Default implementation only handles
753
+ stdlib exceptions common to every plugin: a concrete plugin should
754
+ override to layer in its SDK's error types (``ccxt.AuthenticationError``,
755
+ IB ``errorCode``, etc.) and return ``None`` for anything it doesn't
756
+ recognise so the caller re-raises the original.
757
+
758
+ :returns: A :class:`BrokerError` subclass instance if ``raw`` can be
759
+ classified, or ``None`` if the plugin should re-raise as-is.
760
+ """
761
+ if isinstance(raw, ConnectionError):
762
+ return ExchangeConnectionError(str(raw) or "Connection lost")
763
+ return None
764
+
765
+ # === Capabilities ===
766
+
767
+ @abstractmethod
768
+ def get_capabilities(self) -> 'ExchangeCapabilities':
769
+ """
770
+ Declare what the exchange supports.
771
+
772
+ Called once at startup for validation against script requirements
773
+ (see :func:`~pynecore.core.broker.validation.validate_at_startup`).
774
+
775
+ Each field is a :class:`~pynecore.core.broker.models.CapabilityLevel`:
776
+ ``UNSUPPORTED`` (rejects scripts that need it),
777
+ ``SOFTWARE`` / ``PARTIAL_NATIVE`` / ``NATIVE`` (all pass validation,
778
+ the level is a diagnostic). The sync engine reads ``NATIVE`` as
779
+ "exchange is authoritative; suppress my fallback" for ``oca_cancel``
780
+ and ``tp_sl_bracket``.
781
+ """