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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- 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
|
+
"""
|