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,345 @@
1
+ """One-way position emulation over a hedging- or netting-mode broker.
2
+
3
+ A hedging cTrader account can hold several simultaneous open positions ("legs")
4
+ for one symbol — each with its own broker position id. Pine Script strategies,
5
+ however, see a single *one-way* position per symbol: at most one direction at a
6
+ time, opposite orders reduce and then flip it. This module is the core bridge
7
+ between the two representations, so a broker plugin only has to provide thin
8
+ transport primitives (``fetch_raw_positions`` / ``close_leg`` / ``place_leg``)
9
+ and carries none of the netting logic itself.
10
+
11
+ The two responsibilities live here as pure functions:
12
+
13
+ * :func:`aggregate_positions` collapses the raw legs into the single
14
+ :class:`~pynecore.core.broker.models.ExchangePosition` the sync engine reads.
15
+ * :func:`select_legs_for_close` and :func:`plan_reversal` decide *which* legs to
16
+ close, and by how much, for a reduce / close / reversal — always oldest-first
17
+ (FIFO), matching Pine's trade accounting and giving replay-stable plans.
18
+
19
+ Aggregation tolerates transient opposite-direction legs (e.g. a pre-existing
20
+ manual hedge, or the instant between a reversal's close and open): the minority
21
+ side is netted virtually-FIFO against the oldest majority legs, and the
22
+ surviving side's volume-weighted price becomes the one-way entry price.
23
+ """
24
+
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+
28
+ from pynecore.core.broker.models import ExchangePosition, PositionLeg
29
+
30
+ __all__ = [
31
+ 'LegClose',
32
+ 'ReversalPlan',
33
+ 'net_signed_qty',
34
+ 'aggregate_positions',
35
+ 'select_legs_for_close',
36
+ 'plan_reversal',
37
+ 'plan_leg_close_volumes',
38
+ 'legs_on_position_side',
39
+ 'net_survivor_legs',
40
+ ]
41
+
42
+ # Quantities are in Pine units (fractional lots possible). Anything below this
43
+ # is treated as flat / fully consumed — coarse enough to absorb broker rounding,
44
+ # fine enough never to swallow a real partial lot.
45
+ _QTY_EPS = 1e-9
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class LegClose:
50
+ """A single planned leg reduction: close ``qty`` of broker leg ``leg_id``."""
51
+ leg_id: str
52
+ qty: float
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ReversalPlan:
57
+ """Decomposition of a one-way order against the current legs.
58
+
59
+ Produced by :func:`plan_reversal` for an order whose direction may oppose
60
+ the open position. The order is split into a FIFO close of the opposing
61
+ legs plus a residual open in the order's own direction.
62
+
63
+ :ivar closes: Opposing legs to close (oldest first) and by how much. Empty
64
+ when the order only adds to the existing direction.
65
+ :ivar open_qty: Residual size to open in the order's direction after the
66
+ closes. Zero when the order exactly flattens the opposite exposure.
67
+ :ivar open_side: The order's direction (``"buy"`` / ``"sell"``); the side
68
+ of any residual open.
69
+ """
70
+ closes: tuple[LegClose, ...]
71
+ open_qty: float
72
+ open_side: str
73
+
74
+
75
+ def net_signed_qty(legs: list[PositionLeg]) -> float:
76
+ """Signed net size across legs: long (``buy``) positive, short negative."""
77
+ return sum(leg.qty if leg.side == 'buy' else -leg.qty for leg in legs)
78
+
79
+
80
+ def _surviving_weighted_avg(legs: list[PositionLeg], keep_side: str) -> float:
81
+ """Volume-weighted entry price of ``keep_side`` after virtual-FIFO netting.
82
+
83
+ The opposite-side legs virtually close the oldest ``keep_side`` legs first
84
+ (FIFO), exactly as a real reducing fill would; the price returned is the
85
+ weighted average of whatever ``keep_side`` volume survives. Returns ``0.0``
86
+ when nothing survives (fully netted).
87
+ """
88
+ survivors = sorted(
89
+ (leg for leg in legs if leg.side == keep_side),
90
+ key=lambda leg: (leg.open_time, leg.leg_id),
91
+ )
92
+ offset = sum(leg.qty for leg in legs if leg.side != keep_side)
93
+ qty_acc = 0.0
94
+ notional = 0.0
95
+ for leg in survivors:
96
+ remaining = leg.qty
97
+ if offset > 0.0:
98
+ consumed = min(remaining, offset)
99
+ offset -= consumed
100
+ remaining -= consumed
101
+ if remaining > _QTY_EPS:
102
+ qty_acc += remaining
103
+ notional += remaining * leg.entry_price
104
+ return notional / qty_acc if qty_acc > _QTY_EPS else 0.0
105
+
106
+
107
+ def aggregate_positions(
108
+ symbol: str,
109
+ legs: list[PositionLeg],
110
+ *,
111
+ leverage: float = 1.0,
112
+ margin_mode: str = 'cross',
113
+ ) -> ExchangePosition | None:
114
+ """Collapse raw broker legs into one net one-way position snapshot.
115
+
116
+ The net signed size decides the side; the surviving side's virtual-FIFO
117
+ weighted-average price is the entry price; unrealized P&L is the plain sum
118
+ of every leg's mark-to-market (correct regardless of leg direction).
119
+
120
+ :param symbol: Pine symbol the legs belong to.
121
+ :param legs: All open legs for ``symbol`` (any direction). May be empty.
122
+ :param leverage: Carried onto the snapshot; legs do not report it per-leg.
123
+ :param margin_mode: Carried onto the snapshot.
124
+ :return: The aggregated :class:`ExchangePosition`, or ``None`` when there
125
+ are no legs at all (a genuinely flat symbol — distinct from a netted
126
+ ``flat`` snapshot, which is only produced when offsetting legs remain
127
+ open but sum to zero).
128
+ """
129
+ if not legs:
130
+ return None
131
+ net = net_signed_qty(legs)
132
+ unrealized = sum(leg.unrealized_pnl for leg in legs)
133
+ if abs(net) <= _QTY_EPS:
134
+ # Offsetting legs still open but net flat — surface it as flat with the
135
+ # residual gross P&L so a caller can spot a stuck hedge rather than a
136
+ # clean flat.
137
+ return ExchangePosition(
138
+ symbol=symbol, side='flat', size=0.0, entry_price=0.0,
139
+ unrealized_pnl=unrealized, liquidation_price=None,
140
+ leverage=leverage, margin_mode=margin_mode,
141
+ )
142
+ keep_side = 'buy' if net > 0.0 else 'sell'
143
+ return ExchangePosition(
144
+ symbol=symbol,
145
+ side='long' if net > 0.0 else 'short',
146
+ size=abs(net),
147
+ entry_price=_surviving_weighted_avg(legs, keep_side),
148
+ unrealized_pnl=unrealized,
149
+ liquidation_price=None,
150
+ leverage=leverage,
151
+ margin_mode=margin_mode,
152
+ )
153
+
154
+
155
+ def select_legs_for_close(
156
+ qty: float, legs: list[PositionLeg], side: str,
157
+ ) -> tuple[tuple[LegClose, ...], float]:
158
+ """Pick which ``side`` legs to close for a ``qty`` reduction, oldest first.
159
+
160
+ FIFO by :attr:`~pynecore.core.broker.models.PositionLeg.open_time` so the
161
+ plan matches Pine's oldest-trade-first close order and is deterministic
162
+ across restarts (a replay sees the same legs in the same order).
163
+
164
+ :param qty: Total size to close (positive).
165
+ :param legs: All open legs for the symbol; only those on ``side`` are
166
+ considered.
167
+ :param side: Direction of the legs to reduce (``"buy"`` for a long
168
+ position, ``"sell"`` for a short).
169
+ :return: ``(closes, shortfall)`` — the per-leg close plan and any quantity
170
+ that could not be covered because the open legs summed to less than
171
+ ``qty`` (``0.0`` in the normal case). A non-zero shortfall is the
172
+ caller's signal that the broker holds less than Pine believes.
173
+ """
174
+ candidates = sorted(
175
+ (leg for leg in legs if leg.side == side),
176
+ # ``leg_id`` is the stable secondary key: two legs sharing an
177
+ # ``open_time`` must close in the same order across a restart even if
178
+ # the broker returns them in a different sequence, so the plan stays
179
+ # replay-deterministic as the FIFO docstring promises.
180
+ key=lambda leg: (leg.open_time, leg.leg_id),
181
+ )
182
+ closes: list[LegClose] = []
183
+ remaining = qty
184
+ for leg in candidates:
185
+ if remaining <= _QTY_EPS:
186
+ break
187
+ take = min(leg.qty, remaining)
188
+ if take > _QTY_EPS:
189
+ closes.append(LegClose(leg_id=leg.leg_id, qty=take))
190
+ remaining -= take
191
+ # A residual at or below the tolerance is fully covered as far as the rest
192
+ # of the module is concerned (the loop already stops consuming it), so it
193
+ # must not leak out as a spurious positive shortfall for broker-rounded
194
+ # quantities such as qty=1.0 against an open leg of 0.9999999995.
195
+ return tuple(closes), remaining if remaining > _QTY_EPS else 0.0
196
+
197
+
198
+ def plan_reversal(
199
+ side: str, qty: float, legs: list[PositionLeg],
200
+ ) -> ReversalPlan:
201
+ """Split a one-way order into opposite-leg closes plus a residual open.
202
+
203
+ Pine folds a reversal into one combined order whose size is
204
+ ``target + |opposite_net|`` (it assumes a netting auto-flip). On a hedging
205
+ account that single order would instead open a separate opposing leg and
206
+ bloat gross exposure, so the order is decomposed: close the opposing legs
207
+ FIFO (up to the order size), then open whatever target size remains in the
208
+ order's own direction.
209
+
210
+ Works uniformly for the three shapes a single order can take:
211
+
212
+ * **pure add** — no opposing legs: ``closes`` empty, ``open_qty == qty``;
213
+ * **reversal** — opposing legs smaller than ``qty``: close them all, open
214
+ the remainder (``qty - opposite_total``) in ``side``;
215
+ * **partial reduce via order** — ``qty`` ≤ opposing exposure: close
216
+ ``qty`` worth of opposing legs FIFO, ``open_qty == 0`` (no flip).
217
+
218
+ :param side: The order's direction (``"buy"`` / ``"sell"``).
219
+ :param qty: The combined order size as Pine dispatched it.
220
+ :param legs: Current open legs for the symbol.
221
+ :return: A :class:`ReversalPlan`.
222
+ """
223
+ opposite_side = 'sell' if side == 'buy' else 'buy'
224
+ closes, _shortfall = select_legs_for_close(qty, legs, opposite_side)
225
+ closed_total = sum(close.qty for close in closes)
226
+ open_qty = qty - closed_total
227
+ if open_qty < _QTY_EPS:
228
+ open_qty = 0.0
229
+ return ReversalPlan(closes=closes, open_qty=open_qty, open_side=side)
230
+
231
+
232
+ def plan_leg_close_volumes(
233
+ closes: tuple[LegClose, ...],
234
+ quantize: Callable[[float], int],
235
+ ) -> list[tuple[str, int]]:
236
+ """Fan a FIFO close plan out to per-leg broker-grid close volumes.
237
+
238
+ Quantizing each FIFO slice independently changes the total close size on the
239
+ broker's volume grid: a sub-grid slice rounds to ``0`` (an invalid empty
240
+ close that under-reduces the position) and a fractional-grid slice rounds UP
241
+ past what the leg holds. Snapping the *total* once is not enough either: if a
242
+ slice that carries part of the owed total quantizes to ``0`` on its own —
243
+ e.g. two 10-unit legs on a 1000-centi grid closing 15 units, where the
244
+ second 5-unit slice snaps to ``0`` — per-slice rounded volumes leave the
245
+ snapped total under-dispatched.
246
+
247
+ Instead a single running total is snapped via ``quantize``: each leg receives
248
+ the delta between the snapped cumulative close-through-this-leg and the
249
+ snapped cumulative through the previous leg, capped at the snapped grand
250
+ total. The sub-grid remainder a slice would have dropped is carried into the
251
+ next leg, so the dispatched volumes always sum to the same grand total the
252
+ single-position close path would use, every volume sits on the broker grid,
253
+ and no leg gets more than its own slice rounded up to the next grid step. Any
254
+ leg whose delta is zero is dropped (no zero-volume request is sent).
255
+
256
+ :param closes: FIFO close plan (oldest first); each ``qty`` is the slice to
257
+ take from that leg, in Pine units, never above the leg's open size.
258
+ :param quantize: Maps a Pine-unit quantity to the broker's integer volume
259
+ grid (e.g. cTrader centi-units snapped to ``stepVolume``). Owns the
260
+ broker-specific unit; the FIFO carry logic here stays unit-agnostic.
261
+ :return: ``(leg_id, volume)`` pairs to dispatch in FIFO order, each with the
262
+ broker-grid integer ``volume`` the per-leg close expects. ``leg_id``
263
+ stays the :class:`PositionLeg` string id; the transport casts it.
264
+ """
265
+ grand_total = quantize(sum(close.qty for close in closes))
266
+ out: list[tuple[str, int]] = []
267
+ cumulative_units = 0.0
268
+ dispatched = 0
269
+ for close in closes:
270
+ if dispatched >= grand_total:
271
+ break
272
+ cumulative_units += close.qty
273
+ snapped = min(quantize(cumulative_units), grand_total)
274
+ volume = snapped - dispatched
275
+ if volume <= 0:
276
+ continue
277
+ out.append((close.leg_id, volume))
278
+ dispatched = snapped
279
+ return out
280
+
281
+
282
+ def legs_on_position_side(
283
+ legs: list[PositionLeg],
284
+ ) -> tuple[str, list[PositionLeg]]:
285
+ """Return the aggregate one-way side and the legs that make it up, FIFO.
286
+
287
+ The net signed size decides the side (``'buy'`` for a net-long one-way
288
+ position, ``'sell'`` for net-short); the returned legs are those on that
289
+ side, oldest first (FIFO by ``(open_time, leg_id)``) so bracket replication
290
+ and the fail-safe SL fan-out are replay-stable. A net-flat book (offsetting
291
+ legs summing to zero, or no legs at all) returns ``('flat', [])``.
292
+
293
+ Replaces the ``'buy' if side == 'long' else 'sell'`` + filter that bracket
294
+ replication, modify, clear, and fail-safe each used to inline.
295
+ """
296
+ net = net_signed_qty(legs)
297
+ if abs(net) <= _QTY_EPS:
298
+ return 'flat', []
299
+ side = 'buy' if net > 0.0 else 'sell'
300
+ survivors = sorted(
301
+ (leg for leg in legs if leg.side == side),
302
+ key=lambda leg: (leg.open_time, leg.leg_id),
303
+ )
304
+ return side, survivors
305
+
306
+
307
+ def net_survivor_legs(
308
+ legs: list[PositionLeg],
309
+ ) -> tuple[str, list[PositionLeg]]:
310
+ """Return the one-way side and only the legs that survive virtual-FIFO netting.
311
+
312
+ Like :func:`legs_on_position_side`, but on a *mixed* book (legs open on both
313
+ sides) it drops the oldest majority-side legs that the opposing legs
314
+ virtually FIFO-close, returning only the legs that actually make up the net
315
+ one-way position :func:`aggregate_positions` reports — a leg is kept iff any
316
+ of its volume survives the netting, oldest first. A clean one-way book (no
317
+ opposing legs) is unaffected: every majority leg survives, identical to
318
+ :func:`legs_on_position_side`. A net-flat book returns ``('flat', [])``.
319
+
320
+ Bracket replication uses this rather than :func:`legs_on_position_side` so a
321
+ protective stop is amended onto the net exposure only. A mixed book never
322
+ arises from the emulator itself (it FIFO-closes the opposing side before
323
+ opening), only from a pre-existing manual hedge or a crash-interrupted
324
+ reversal; there the gross majority legs exceed the net, and protecting all of
325
+ them would close more than the position and flip it to the minority side.
326
+ """
327
+ net = net_signed_qty(legs)
328
+ if abs(net) <= _QTY_EPS:
329
+ return 'flat', []
330
+ side = 'buy' if net > 0.0 else 'sell'
331
+ survivors = sorted(
332
+ (leg for leg in legs if leg.side == side),
333
+ key=lambda leg: (leg.open_time, leg.leg_id),
334
+ )
335
+ offset = sum(leg.qty for leg in legs if leg.side != side)
336
+ kept: list[PositionLeg] = []
337
+ for leg in survivors:
338
+ remaining = leg.qty
339
+ if offset > 0.0:
340
+ consumed = min(remaining, offset)
341
+ offset -= consumed
342
+ remaining -= consumed
343
+ if remaining > _QTY_EPS:
344
+ kept.append(leg)
345
+ return side, kept
@@ -0,0 +1,346 @@
1
+ """
2
+ Broker-related exception hierarchy.
3
+
4
+ All broker plugin and order-sync errors derive from :class:`BrokerError`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from pynecore.core.broker.models import ExchangeOrder
12
+
13
+ __all__ = [
14
+ 'AuthenticationError',
15
+ 'BracketAttachAfterFillRejectedError',
16
+ 'BrokerError',
17
+ 'BrokerManualInterventionError',
18
+ 'ClientOrderIdSpentError',
19
+ 'ExchangeCapabilityError',
20
+ 'ExchangeConnectionError',
21
+ 'ExchangeOrderRejectedError',
22
+ 'ExchangeRateLimitError',
23
+ 'InsufficientMarginError',
24
+ 'OrderDispositionUnknownError',
25
+ 'OrderSkippedByPlugin',
26
+ 'OrderSyncError',
27
+ 'SpotInventoryConflictError',
28
+ 'UnexpectedCancelError',
29
+ ]
30
+
31
+
32
+ class BrokerError(RuntimeError):
33
+ """Base class for all broker-related errors."""
34
+
35
+
36
+ class AuthenticationError(BrokerError):
37
+ """The exchange rejected the plugin's credentials.
38
+
39
+ Raised when the exchange returns 401 / 403, reports an invalid API key,
40
+ or bans the source IP. Semantics are **terminal** — reconnect cannot
41
+ recover, the user must fix the credentials. The Script Runner treats
42
+ this as a graceful-stop condition at startup and surfaces an
43
+ :class:`~pynecore.core.broker.models.AuthenticationFailedEvent` so the
44
+ observability layer can page.
45
+
46
+ :ivar reason: Short human-readable cause (echoed on the event).
47
+ """
48
+
49
+ def __init__(self, message: str, reason: str = "") -> None:
50
+ super().__init__(message)
51
+ self.reason = reason or message
52
+
53
+
54
+ class ExchangeCapabilityError(BrokerError):
55
+ """The exchange does not support a required feature.
56
+
57
+ Raised by a BrokerPlugin when asked to do something its exchange cannot do
58
+ (e.g. a TP+SL bracket with OCA reduce semantics on an exchange without
59
+ native support for it). Treated as a graceful-stop condition at startup.
60
+ """
61
+
62
+
63
+ class ExchangeConnectionError(BrokerError):
64
+ """Connection to the exchange was lost.
65
+
66
+ The Order Sync Engine is expected to reconnect and then reconcile state
67
+ before resuming normal operation.
68
+ """
69
+
70
+
71
+ class ExchangeOrderRejectedError(BrokerError):
72
+ """The exchange rejected an order.
73
+
74
+ :ivar order: The rejected order as it is known locally, or ``None`` if the
75
+ order never made it far enough to have an exchange representation.
76
+ """
77
+
78
+ def __init__(self, message: str, order: 'ExchangeOrder | None' = None) -> None:
79
+ super().__init__(message)
80
+ self.order = order
81
+
82
+
83
+ class InsufficientMarginError(ExchangeOrderRejectedError):
84
+ """The exchange rejected an order for insufficient margin / balance.
85
+
86
+ A typed sub-class of :class:`ExchangeOrderRejectedError` so the risk
87
+ layer can pattern-match the reason without string-parsing the exchange
88
+ message. Intent-level reject — non-terminal, the runner keeps going and
89
+ the strategy can respond (e.g. shrink size, back off).
90
+ """
91
+
92
+
93
+ class ClientOrderIdSpentError(ExchangeOrderRejectedError):
94
+ """A create was refused because the deterministic client order id is
95
+ already consumed by a now-terminal order, on a venue that never allows
96
+ client-id reuse (Bybit rejects re-creation under a cancelled order's
97
+ ``orderLinkId``: spot retCode 170141, derivatives 110072 — measured
98
+ live). The default cancel+recreate modify path re-sends the same
99
+ pinned id, so on such venues the recreate collides with the id the
100
+ cancel just spent.
101
+
102
+ Contract for plugins: raise ONLY after verifying that nothing is live
103
+ under the id (the duplicate lookup returned a terminal, dead order —
104
+ not a fill) and after cleaning up any sibling orders the same dispatch
105
+ attempt already placed. The engine responds by re-anchoring the
106
+ intent's envelope on a bumped ``retry_seq`` and re-dispatching
107
+ immediately, so the replacement lands under a fresh id instead of the
108
+ dead order being silently adopted as live.
109
+ """
110
+
111
+
112
+ class BracketAttachAfterFillRejectedError(ExchangeOrderRejectedError):
113
+ """A protective TP/SL bracket attach was rejected AFTER the parent
114
+ ENTRY/EXIT fill committed on the exchange.
115
+
116
+ Distinct from a plain :class:`ExchangeOrderRejectedError` where no
117
+ parent fill occurred (e.g. an entry rejected before contacting the
118
+ exchange — nothing open, nothing to defend). Here the position is
119
+ *open and unprotected*: the sync engine MUST NOT halt (which would
120
+ leave the unprotected fill exposed indefinitely), instead it issues a
121
+ defensive market close to take the position flat.
122
+
123
+ The plugin raises this *after* rolling back the persisted leg rows
124
+ (so the BrokerStore does not leak phantom protective legs). The sync
125
+ engine's :meth:`_dispatch_new` recovery path catches it, builds a
126
+ synthetic :class:`~pynecore.core.broker.models.CloseIntent` for the
127
+ parent position, and dispatches that. The runner continues.
128
+
129
+ Engine-side recovery uses a derived
130
+ :class:`~pynecore.core.broker.models.BracketAttachRejectContext` as
131
+ the formal hand-off to the plugin's
132
+ :meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_residual_orders_after_bracket_attach_reject`
133
+ method — this exception is the *transport* (chained cause, message),
134
+ the context is the *contract* (data the engine needs to settle the
135
+ defensive close).
136
+
137
+ :ivar position_coid: Client-order-id of the unprotected open position
138
+ (the parent ENTRY row in BrokerStore). Universal recovery key
139
+ across plugins — required.
140
+ :ivar position_side: Side of the OPEN position (``"buy"`` for long,
141
+ ``"sell"`` for short). The defensive close picks the opposite.
142
+ :ivar qty: Quantity of the unprotected position (units the close
143
+ intent must flatten). ``0`` is a *proven-flat* signal — the
144
+ plugin measured that a racing sibling fill already consumed the
145
+ entire bracket quantity; the engine then skips the defensive
146
+ close (nothing remains to flatten) while still running the
147
+ OCA-cancel / residual-cleanup cascade.
148
+ :ivar symbol: Trading symbol of the unprotected position.
149
+ :ivar position_deal_id: Exchange-side identifier of the unprotected
150
+ open position, when the plugin can supply one. Broker-specific
151
+ (Capital.com deal id, IB permId, Bybit orderId, ...); optional —
152
+ recovery must not rely on it for correctness.
153
+ :ivar from_entry: Pine ``strategy.entry`` id, when the bracket attach
154
+ originated from a Pine ``strategy.exit``. Used for log
155
+ correlation; not required for the close itself.
156
+ :ivar exit_id: Pine ``strategy.exit`` id when applicable.
157
+ :ivar filled_qty: Parent quantity already filled at the time of the
158
+ reject. ``None`` falls back to ``qty`` (conservative).
159
+ :ivar error_code: Exchange-side error code, if any.
160
+ :ivar error_message: Exchange-side error message, if any.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ message: str,
166
+ *,
167
+ position_coid: str,
168
+ symbol: str,
169
+ position_side: str,
170
+ qty: float,
171
+ position_deal_id: str | None = None,
172
+ from_entry: str | None = None,
173
+ exit_id: str | None = None,
174
+ filled_qty: float | None = None,
175
+ error_code: str | None = None,
176
+ error_message: str | None = None,
177
+ ) -> None:
178
+ super().__init__(message)
179
+ self.position_coid = position_coid
180
+ self.symbol = symbol
181
+ self.position_side = position_side
182
+ self.qty = qty
183
+ self.position_deal_id = position_deal_id
184
+ self.from_entry = from_entry
185
+ self.exit_id = exit_id
186
+ self.filled_qty = filled_qty
187
+ self.error_code = error_code
188
+ self.error_message = error_message
189
+
190
+
191
+ class ExchangeRateLimitError(BrokerError):
192
+ """Exchange rate limit was hit.
193
+
194
+ :ivar retry_after: Seconds the caller should wait before retrying.
195
+ """
196
+
197
+ def __init__(self, message: str, retry_after: float) -> None:
198
+ super().__init__(message)
199
+ self.retry_after = retry_after
200
+
201
+
202
+ class OrderDispositionUnknownError(BrokerError):
203
+ """A dispatch completed without a definitive accept-or-reject from the exchange.
204
+
205
+ Raised by a BrokerPlugin when a submission times out mid-flight or the
206
+ connection drops before the exchange acknowledges the order — the plugin
207
+ genuinely does not know whether the order landed. Semantics are
208
+ deliberately distinct from :class:`ExchangeConnectionError` (recoverable
209
+ via reconnect) and :class:`ExchangeOrderRejectedError` (the order is known
210
+ not to exist): the Order Sync Engine reacts by holding the dispatch in a
211
+ pending-verification queue and matching against
212
+ :meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_open_orders` on the
213
+ next sync, keyed by ``client_order_id``.
214
+
215
+ :ivar client_order_id: The id the plugin attempted to submit with. The
216
+ sync engine uses it to match the open-orders query back to the
217
+ originating dispatch.
218
+ :ivar cause: The underlying raw exception, if any, preserved for logging.
219
+ :ivar predecessor_cancel_ids: For an ambiguous MODIFY dispatch, the
220
+ modify shape the plugin executed before the disposition turned
221
+ unknown. ``None`` (default) — shape undeclared: the sync engine
222
+ assumes the plugin's default cancel + re-execute modify and treats a
223
+ venue CANCELLED push for any currently mapped order id during the
224
+ park as the engine-initiated predecessor confirmation. An empty
225
+ tuple — atomic in-place amend, NO predecessor cancel was issued: a
226
+ CANCELLED push during the park is a genuine external cancel and
227
+ fires the ``on_unexpected_cancel`` policy. A non-empty tuple — the
228
+ exchange order ids the plugin cancel-issued before the ambiguous
229
+ replacement submission; exactly those pushes are consumed as
230
+ engine-initiated. Meaningful only when raised from
231
+ ``modify_entry`` / ``modify_exit``-shaped plugin calls.
232
+ """
233
+
234
+ def __init__(
235
+ self,
236
+ message: str,
237
+ client_order_id: str,
238
+ cause: Exception | None = None,
239
+ predecessor_cancel_ids: tuple[str, ...] | None = None,
240
+ ) -> None:
241
+ super().__init__(message)
242
+ self.client_order_id = client_order_id
243
+ self.cause = cause
244
+ self.predecessor_cancel_ids = predecessor_cancel_ids
245
+
246
+
247
+ class OrderSkippedByPlugin(BrokerError):
248
+ """Plugin proactively declined to dispatch — not a failure, no order sent.
249
+
250
+ Distinct from :class:`ExchangeOrderRejectedError` (the exchange said no)
251
+ and :class:`OrderDispositionUnknownError` (network-ambiguous): the plugin
252
+ short-circuited *before* contacting the exchange because the intent does
253
+ not satisfy a known venue constraint (e.g. ``intent.qty`` below the
254
+ instrument's ``min_size``). The Order Sync Engine logs a broker-warning
255
+ and refuses to register the intent in ``_active_intents``, so the next
256
+ bar re-evaluates the intent freely — a runtime sizing model that yields
257
+ a placeable qty later just trades normally without recovery glue.
258
+
259
+ :ivar intent_key: The intent's diff key, for engine-side lookups.
260
+ :ivar reason: Short stable token (e.g. ``"below_min_size"``) for log
261
+ filtering and programmatic policy.
262
+ :ivar context: Plugin-supplied diagnostic dictionary (symbol, qty,
263
+ min_size, …) for operator triage.
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ message: str,
269
+ *,
270
+ intent_key: str,
271
+ reason: str = "",
272
+ context: dict | None = None,
273
+ ) -> None:
274
+ super().__init__(message)
275
+ self.intent_key = intent_key
276
+ self.reason = reason
277
+ self.context = context or {}
278
+
279
+
280
+ class OrderSyncError(BrokerError):
281
+ """Exchange state diverged from the expected internal state."""
282
+
283
+
284
+ class BrokerManualInterventionError(BrokerError):
285
+ """Automated execution cannot safely continue — a human must resolve
286
+ broker-side ambiguity before the strategy runs again.
287
+
288
+ Raised by a BrokerPlugin when reconcile or recovery logic encounters a
289
+ state that cannot be resolved without risking an incorrect trade — e.g.
290
+ a partial-close race whose reverse leg cannot be confidently identified,
291
+ or a crash-recovery heuristic that finds multiple candidate deals for a
292
+ single submitted intent. Semantics are **terminal**: the sync engine
293
+ halts all further dispatches and the runner performs a graceful stop.
294
+ Distinct from :class:`ExchangeOrderRejectedError` (the order is known
295
+ not to exist) and :class:`OrderDispositionUnknownError` (the plugin
296
+ parks and retries on next sync) — manual intervention signals that
297
+ *automated* recovery cannot proceed safely.
298
+
299
+ :ivar reason: Human-readable description of the ambiguity.
300
+ :ivar intent_key: The intent's diff key, if the ambiguity relates to a
301
+ specific in-flight intent. ``None`` for orphan/recovery cases.
302
+ :ivar context: Plugin-supplied diagnostic dictionary (e.g. candidate
303
+ deal ids, snapshot totals, timing) for operator triage.
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ reason: str,
309
+ *,
310
+ intent_key: str | None = None,
311
+ context: dict | None = None,
312
+ ) -> None:
313
+ super().__init__(reason)
314
+ self.reason = reason
315
+ self.intent_key = intent_key
316
+ self.context = context or {}
317
+
318
+
319
+ class SpotInventoryConflictError(BrokerManualInterventionError):
320
+ """The spot balance invariant broke or the bot's inventory ownership
321
+ could not be established.
322
+
323
+ Raised by the :class:`~pynecore.core.broker.spot_inventory.SpotInventoryManager`
324
+ when the ``halt`` inventory-conflict policy is active (or as the
325
+ fail-safe fallback when the quarantine hook is missing / raising):
326
+ an unexplainable base-balance drift in either direction, a corrupt
327
+ or foreign ledger row, an inconclusive execution catch-up, or a
328
+ lost asset-ownership lease. External intervention in the bot's
329
+ inventory is not supported — there is no adoption path, so the safe
330
+ terminal signal is manual intervention followed by an operator
331
+ ``rebaseline``.
332
+ """
333
+
334
+
335
+ class UnexpectedCancelError(BrokerManualInterventionError):
336
+ """A bot-owned order disappeared without the bot having cancelled it.
337
+
338
+ Indicates external interference (manual user action, exchange-side
339
+ maintenance, margin-induced cancel, etc.). Modelled as a manual-
340
+ intervention error because automated recovery cannot reason about why
341
+ the order vanished — the safe default is to halt and let a human
342
+ inspect. The sync engine's :meth:`run_event_stream` and dispatch paths
343
+ catch :class:`BrokerManualInterventionError` uniformly, so the same
344
+ graceful-stop pipeline applies whether the trigger came from a polling
345
+ snapshot or from an in-flight dispatch.
346
+ """