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,1128 @@
|
|
|
1
|
+
"""One-way position emulation engine over a hedging-mode broker account.
|
|
2
|
+
|
|
3
|
+
A hedging account holds several open positions ("legs") per symbol; a Pine
|
|
4
|
+
strategy sees a single *one-way* position. The pure helpers in
|
|
5
|
+
:mod:`~pynecore.core.broker.emulator` decide *what* to do (net the legs, pick a
|
|
6
|
+
FIFO close plan, snap per-leg volumes); this engine *drives* it: it fans a
|
|
7
|
+
:class:`~pynecore.core.broker.models.CloseIntent` out across the legs through a
|
|
8
|
+
thin :class:`~pynecore.core.plugin.broker.PositionPort`, and owns the
|
|
9
|
+
persist-first ledger that lets an interrupted fan-out resume on restart without
|
|
10
|
+
re-closing a leg that already settled.
|
|
11
|
+
|
|
12
|
+
It is the close/reversal counterpart of
|
|
13
|
+
:class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
|
|
14
|
+
and :class:`~pynecore.core.broker.software_partial_bracket_engine.SoftwarePartialBracketEngine`:
|
|
15
|
+
one instance per :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine`, the
|
|
16
|
+
persisted close-leg rows in :mod:`store_helpers` are the durable representation
|
|
17
|
+
(PERSIST-FIRST), and :meth:`restart_replay` re-derives an unfinished fan-out
|
|
18
|
+
from them. The legs' close FILLs themselves flow through the engine's ordinary
|
|
19
|
+
natural-close path (mapped to ``LegType.CLOSE`` by the plugin), so this engine
|
|
20
|
+
never touches the fill router — only the dispatch + its crash/replay.
|
|
21
|
+
|
|
22
|
+
The engine carries no broker, clock, or exchange-model state. Each broker action
|
|
23
|
+
goes through the :class:`PositionPort`; aggregation / selection / quantization
|
|
24
|
+
are the pure functions in :mod:`emulator`. That keeps it deterministic and
|
|
25
|
+
unit-testable in isolation, with ``store_ctx=None`` for the no-persistence path.
|
|
26
|
+
"""
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from pynecore.core.broker.emulator import (
|
|
31
|
+
LegClose,
|
|
32
|
+
aggregate_positions,
|
|
33
|
+
net_survivor_legs,
|
|
34
|
+
plan_leg_close_volumes,
|
|
35
|
+
plan_reversal,
|
|
36
|
+
select_legs_for_close,
|
|
37
|
+
)
|
|
38
|
+
from pynecore.core.broker.exceptions import (
|
|
39
|
+
BracketAttachAfterFillRejectedError,
|
|
40
|
+
ExchangeConnectionError,
|
|
41
|
+
ExchangeOrderRejectedError,
|
|
42
|
+
OrderDispositionUnknownError,
|
|
43
|
+
OrderSkippedByPlugin,
|
|
44
|
+
)
|
|
45
|
+
from pynecore.core.broker.idempotency import (
|
|
46
|
+
CLIENT_ORDER_ID_MAX_LEN,
|
|
47
|
+
KIND_CANCEL,
|
|
48
|
+
KIND_CLOSE,
|
|
49
|
+
KIND_ENTRY,
|
|
50
|
+
KIND_ENTRY_STOP,
|
|
51
|
+
KIND_EXIT_SL,
|
|
52
|
+
parse_client_order_id,
|
|
53
|
+
parse_wire_client_order_id,
|
|
54
|
+
)
|
|
55
|
+
from pynecore.core.broker.models import (
|
|
56
|
+
CancelIntent,
|
|
57
|
+
CloseIntent,
|
|
58
|
+
DispatchEnvelope,
|
|
59
|
+
EntryIntent,
|
|
60
|
+
ExitIntent,
|
|
61
|
+
OrderType,
|
|
62
|
+
PositionLeg,
|
|
63
|
+
)
|
|
64
|
+
from pynecore.lib.log import broker_warning as _blog_warning
|
|
65
|
+
from pynecore.core.broker.store_helpers import (
|
|
66
|
+
BRACKET_OWN_STATE_CLEARING,
|
|
67
|
+
BRACKET_OWN_STATE_RELEASED,
|
|
68
|
+
CLOSE_LEG_STATE_DISPATCHED,
|
|
69
|
+
EXTRAS_KEY_BRACKET_OWN_ATTACH_COID,
|
|
70
|
+
EXTRAS_KEY_BRACKET_OWN_CLEAR_COID,
|
|
71
|
+
EXTRAS_KEY_BRACKET_OWN_LEG_ID,
|
|
72
|
+
EXTRAS_KEY_BRACKET_OWN_SL,
|
|
73
|
+
EXTRAS_KEY_BRACKET_OWN_STATE,
|
|
74
|
+
EXTRAS_KEY_BRACKET_OWN_TP,
|
|
75
|
+
EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET,
|
|
76
|
+
EXTRAS_KEY_CLOSE_LEG_ID,
|
|
77
|
+
EXTRAS_KEY_CLOSE_LEG_VOLUME,
|
|
78
|
+
EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS,
|
|
79
|
+
EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID,
|
|
80
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ,
|
|
81
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG,
|
|
82
|
+
clear_residual_open_row,
|
|
83
|
+
create_bracket_ownership_row,
|
|
84
|
+
create_close_leg_row,
|
|
85
|
+
create_residual_open_row,
|
|
86
|
+
iter_active_bracket_ownerships,
|
|
87
|
+
iter_active_close_legs,
|
|
88
|
+
iter_active_residual_opens,
|
|
89
|
+
update_bracket_ownership_state,
|
|
90
|
+
update_close_leg_state,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if TYPE_CHECKING:
|
|
94
|
+
from collections.abc import Callable
|
|
95
|
+
|
|
96
|
+
from pynecore.core.broker.storage import RunContext
|
|
97
|
+
from pynecore.core.plugin.broker import PositionPort
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
'CloseFanResult',
|
|
102
|
+
'ReversalFanResult',
|
|
103
|
+
'BracketFanResult',
|
|
104
|
+
'ClearFanResult',
|
|
105
|
+
'OneWayEmulator',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class CloseFanResult:
|
|
111
|
+
"""Outcome of one fanned-out close, for the caller's return shaping / audit.
|
|
112
|
+
|
|
113
|
+
:ivar legs: ``(leg_id, broker-grid volume)`` pairs actually dispatched, in
|
|
114
|
+
FIFO order. Empty when nothing was open or the close was skipped.
|
|
115
|
+
:ivar shortfall: Pine-unit quantity the open legs could not cover (the
|
|
116
|
+
broker holds less than Pine believes). ``0.0`` in the normal case; a
|
|
117
|
+
positive value is the caller's signal to log, not halt.
|
|
118
|
+
:ivar skipped: ``True`` when the whole close quantized below the broker's
|
|
119
|
+
volume grid, so no order was sent (a non-halting skip — the engine
|
|
120
|
+
re-evaluates next bar). Distinct from an empty ``legs`` with
|
|
121
|
+
``skipped=False``, which means the symbol was already flat.
|
|
122
|
+
"""
|
|
123
|
+
legs: tuple[tuple[str, int], ...]
|
|
124
|
+
shortfall: float
|
|
125
|
+
skipped: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ReversalFanResult:
|
|
130
|
+
"""Outcome of one decomposed reversal / add, for the caller's return shaping.
|
|
131
|
+
|
|
132
|
+
:ivar closes: ``(leg_id, broker-grid volume)`` pairs FIFO-closed to retire
|
|
133
|
+
the opposing exposure. Empty for a pure add (no opposing legs).
|
|
134
|
+
:ivar open_qty: Pine-unit size opened in the order's own direction after the
|
|
135
|
+
closes. ``0.0`` for an exact flatten (close all, open nothing).
|
|
136
|
+
:ivar opened_orders: The ``ExchangeOrder``(s) the residual open produced.
|
|
137
|
+
Empty when ``open_qty`` was zero.
|
|
138
|
+
"""
|
|
139
|
+
closes: tuple[tuple[str, int], ...]
|
|
140
|
+
open_qty: float
|
|
141
|
+
opened_orders: tuple
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class BracketFanResult:
|
|
146
|
+
"""Outcome of one exit-bracket replication across a position's legs.
|
|
147
|
+
|
|
148
|
+
:ivar legs: Broker leg ids the bracket was replicated onto, in FIFO order.
|
|
149
|
+
:ivar skipped: ``True`` when the symbol was flat (no position-side legs to
|
|
150
|
+
protect) — a non-halting no-op; the caller re-evaluates next bar.
|
|
151
|
+
"""
|
|
152
|
+
legs: tuple[str, ...]
|
|
153
|
+
skipped: bool
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class ClearFanResult:
|
|
158
|
+
"""Outcome of one ownership-scoped exit-bracket clear.
|
|
159
|
+
|
|
160
|
+
:ivar legs: Broker leg ids actually cleared — the subset the cancelled exit
|
|
161
|
+
owns, never the whole position side. Empty when the exit owned nothing
|
|
162
|
+
(already released, or no persisted ownership index).
|
|
163
|
+
"""
|
|
164
|
+
legs: tuple[str, ...]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class OneWayEmulator:
|
|
168
|
+
"""Drives Pine one-way close/reversal over a hedging account's legs.
|
|
169
|
+
|
|
170
|
+
One instance per :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine`.
|
|
171
|
+
The engine owns the persisted close-leg ledger and its restart replay; every
|
|
172
|
+
broker-side action is performed through the :class:`PositionPort` the
|
|
173
|
+
emulating plugin exposes.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
store_ctx: 'RunContext | None',
|
|
179
|
+
*,
|
|
180
|
+
mintick: float = 1.0,
|
|
181
|
+
block_exposure_reopens: 'Callable[[], bool] | None' = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
self._store_ctx = store_ctx
|
|
184
|
+
self._mintick = mintick
|
|
185
|
+
#: Engine-supplied gate consulted before a residual-open re-dispatch
|
|
186
|
+
#: (the reversal reopen leg). Wired to the engine's quarantine latch:
|
|
187
|
+
#: under quarantine the owed FIFO closes still run (risk-reducing)
|
|
188
|
+
#: but the reopen is withheld and its breadcrumb stays live, so the
|
|
189
|
+
#: committed reversal finishes after the operator restart.
|
|
190
|
+
self._block_exposure_reopens = block_exposure_reopens
|
|
191
|
+
|
|
192
|
+
def _trail_price_distance(self, trail_offset: float | None) -> float | None:
|
|
193
|
+
"""Convert Pine trailing-offset ticks to the PositionPort price unit."""
|
|
194
|
+
if trail_offset is None:
|
|
195
|
+
return None
|
|
196
|
+
return trail_offset * self._mintick
|
|
197
|
+
|
|
198
|
+
# === Close fan-out ====================================================
|
|
199
|
+
|
|
200
|
+
async def run_close(
|
|
201
|
+
self, envelope: 'DispatchEnvelope', port: 'PositionPort',
|
|
202
|
+
) -> CloseFanResult:
|
|
203
|
+
"""Fan a :class:`CloseIntent` out as per-leg FIFO closes.
|
|
204
|
+
|
|
205
|
+
Reads the live legs, nets them, selects the oldest legs on the position
|
|
206
|
+
side to cover ``intent.qty``, snaps the plan to the broker grid, and
|
|
207
|
+
dispatches one :meth:`PositionPort.close_leg` per leg — persisting each
|
|
208
|
+
leg PERSIST-FIRST so a crash mid-fan resumes via :meth:`restart_replay`.
|
|
209
|
+
|
|
210
|
+
Never raises for an undersized close: a whole-close-below-grid returns
|
|
211
|
+
``skipped=True`` and a broker-holds-less returns a positive
|
|
212
|
+
``shortfall`` — the bot keeps running and the caller decides how to
|
|
213
|
+
surface it.
|
|
214
|
+
"""
|
|
215
|
+
intent = envelope.intent
|
|
216
|
+
assert isinstance(intent, CloseIntent)
|
|
217
|
+
legs = await port.fetch_raw_positions(intent.symbol)
|
|
218
|
+
pos = aggregate_positions(intent.symbol, legs)
|
|
219
|
+
if pos is None or pos.side == 'flat':
|
|
220
|
+
# Nothing open, or offsetting legs net to flat — benign no-op.
|
|
221
|
+
return CloseFanResult(legs=(), shortfall=0.0, skipped=False)
|
|
222
|
+
# CloseIntent.side is the close direction ("sell" closes a long); the
|
|
223
|
+
# legs to reduce sit on the opposite side of the book.
|
|
224
|
+
leg_side = 'buy' if intent.side == 'sell' else 'sell'
|
|
225
|
+
closes, shortfall = select_legs_for_close(intent.qty, legs, leg_side)
|
|
226
|
+
if not closes:
|
|
227
|
+
return CloseFanResult(legs=(), shortfall=shortfall, skipped=False)
|
|
228
|
+
await self._reject_unsupported_partial_closes(
|
|
229
|
+
closes, legs, symbol=intent.symbol,
|
|
230
|
+
intent_key=intent.intent_key, port=port,
|
|
231
|
+
)
|
|
232
|
+
parent_coid = envelope.client_order_id(KIND_CLOSE)
|
|
233
|
+
dispatched = await self._fan_out_closes(
|
|
234
|
+
closes, symbol=intent.symbol, side=intent.side,
|
|
235
|
+
intent_key=intent.intent_key, pine_id=intent.pine_id,
|
|
236
|
+
parent_coid=parent_coid, port=port,
|
|
237
|
+
)
|
|
238
|
+
if not dispatched:
|
|
239
|
+
# Every slice rounded below the broker grid — skip, do not halt.
|
|
240
|
+
return CloseFanResult(legs=(), shortfall=shortfall, skipped=True)
|
|
241
|
+
return CloseFanResult(
|
|
242
|
+
legs=tuple(dispatched), shortfall=shortfall, skipped=False,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def run_reversal(
|
|
246
|
+
self, envelope: 'DispatchEnvelope', port: 'PositionPort',
|
|
247
|
+
) -> ReversalFanResult:
|
|
248
|
+
"""Decompose a Pine reversal/add entry over the hedging legs.
|
|
249
|
+
|
|
250
|
+
Pine folds a reversal into one combined-size :class:`EntryIntent`
|
|
251
|
+
assuming a netting auto-flip. On a hedging account that single order
|
|
252
|
+
would open a separate opposing leg, so it is split: FIFO-close the
|
|
253
|
+
opposing legs (up to the order size) and open only the residual in the
|
|
254
|
+
order's own direction. Pure add (no opposing legs) opens the whole size;
|
|
255
|
+
exact flatten (opposing exposure == order size) closes all and opens
|
|
256
|
+
nothing.
|
|
257
|
+
"""
|
|
258
|
+
intent = envelope.intent
|
|
259
|
+
assert isinstance(intent, EntryIntent)
|
|
260
|
+
if intent.order_type is not OrderType.MARKET:
|
|
261
|
+
# A resting LIMIT / STOP entry is not Pine's combined-size market
|
|
262
|
+
# auto-flip: it rests as a fresh working order and is decomposed
|
|
263
|
+
# (if it is still a reversal when it triggers) only at FILL time.
|
|
264
|
+
# Place it full-size now with NO opposing-leg close — retiring the
|
|
265
|
+
# opposing exposure here would flatten the position before the entry
|
|
266
|
+
# ever fires. (Fill-time decomposition of a resting reversal is a
|
|
267
|
+
# follow-up.) ``place_leg`` runs the broker's own volume-bounds
|
|
268
|
+
# pre-flight, so an out-of-range resting order still skips cleanly.
|
|
269
|
+
opened = tuple(await port.place_leg(envelope, intent.qty))
|
|
270
|
+
return ReversalFanResult(
|
|
271
|
+
closes=(), open_qty=intent.qty, opened_orders=opened,
|
|
272
|
+
)
|
|
273
|
+
legs = await port.fetch_raw_positions(intent.symbol)
|
|
274
|
+
plan = plan_reversal(intent.side, intent.qty, legs)
|
|
275
|
+
# Pre-flight partial-leg capability BEFORE anything is persisted or
|
|
276
|
+
# sent — same atomicity rationale as the volume-bounds check below.
|
|
277
|
+
await self._reject_unsupported_partial_closes(
|
|
278
|
+
plan.closes, legs, symbol=intent.symbol,
|
|
279
|
+
intent_key=intent.intent_key, port=port,
|
|
280
|
+
)
|
|
281
|
+
# Pre-flight the broker volume bounds BEFORE any close lands: an
|
|
282
|
+
# out-of-range order must raise the non-halting skip while it is still
|
|
283
|
+
# true, otherwise the closes reduce the book yet the whole reversal is
|
|
284
|
+
# reported skipped, desyncing the engine. A pure reduce (open_qty == 0)
|
|
285
|
+
# validates the combined order size; a real reversal validates its
|
|
286
|
+
# residual leg.
|
|
287
|
+
preflight_qty = plan.open_qty if plan.open_qty > 0.0 else intent.qty
|
|
288
|
+
await port.reject_out_of_range(envelope, preflight_qty)
|
|
289
|
+
parent_coid = envelope.client_order_id(KIND_CLOSE)
|
|
290
|
+
# Persist-first residual-open breadcrumb BEFORE the closes: a crash
|
|
291
|
+
# after the closes land but before ``place_leg`` persists the residual
|
|
292
|
+
# entry row would otherwise leave the book reduced with the residual
|
|
293
|
+
# open lost — neither the close-leg replay nor the entry journal owns
|
|
294
|
+
# it. ``restart_replay`` reconciles this against the residual entry row
|
|
295
|
+
# and re-dispatches only if that row never landed. Only a genuine
|
|
296
|
+
# reversal needs it: a pure add (no opposing legs, ``plan.closes``
|
|
297
|
+
# empty) leaves the book intact, so re-opening it on restart would
|
|
298
|
+
# bypass the next sync's Pine re-evaluation and resurrect an entry the
|
|
299
|
+
# strategy may no longer want. The breadcrumb is gated on closes being
|
|
300
|
+
# actually owed, never on a positive ``open_qty`` alone.
|
|
301
|
+
residual_coid = f"{parent_coid}:residual"
|
|
302
|
+
wrote_breadcrumb = bool(plan.closes) and plan.open_qty > 0.0
|
|
303
|
+
if wrote_breadcrumb and self._store_ctx is not None:
|
|
304
|
+
# The breadcrumb's entry coid must match the kind ``place_leg`` will
|
|
305
|
+
# persist under: a stop-fired market entry dispatches as
|
|
306
|
+
# ``KIND_ENTRY_STOP``, so anchoring on ``KIND_ENTRY`` there would
|
|
307
|
+
# make the replay's row-existence check miss the landed entry row
|
|
308
|
+
# and double-open the residual.
|
|
309
|
+
entry_kind = KIND_ENTRY_STOP if intent.stop_fired_market else KIND_ENTRY
|
|
310
|
+
create_residual_open_row(
|
|
311
|
+
self._store_ctx,
|
|
312
|
+
coid=residual_coid,
|
|
313
|
+
symbol=intent.symbol,
|
|
314
|
+
side=intent.side,
|
|
315
|
+
qty=plan.open_qty,
|
|
316
|
+
intent_key=intent.intent_key,
|
|
317
|
+
pine_entry_id=intent.pine_id,
|
|
318
|
+
entry_coid=envelope.client_order_id(entry_kind),
|
|
319
|
+
run_tag=envelope.run_tag,
|
|
320
|
+
bar_ts_ms=envelope.bar_ts_ms,
|
|
321
|
+
retry_seq=envelope.retry_seq,
|
|
322
|
+
)
|
|
323
|
+
dispatched: list[tuple[str, int]] = []
|
|
324
|
+
if plan.closes:
|
|
325
|
+
try:
|
|
326
|
+
dispatched = await self._fan_out_closes(
|
|
327
|
+
plan.closes, symbol=intent.symbol, side=intent.side,
|
|
328
|
+
intent_key=intent.intent_key, pine_id=intent.pine_id,
|
|
329
|
+
parent_coid=parent_coid, port=port,
|
|
330
|
+
)
|
|
331
|
+
except ExchangeOrderRejectedError:
|
|
332
|
+
# A close leg was DEFINITIVELY refused — the fan-out raised
|
|
333
|
+
# before the residual ``place_leg`` ran, so the residual never
|
|
334
|
+
# opened. ``_dispatch_new`` turns this reject into a non-halting
|
|
335
|
+
# skip for the entry (signal dropped, bot continues), so the
|
|
336
|
+
# breadcrumb must be discharged here exactly as the residual
|
|
337
|
+
# reject path below does: leaving it live would let a later
|
|
338
|
+
# restart replay re-close + re-open a reversal the exchange
|
|
339
|
+
# already rejected and Pine never re-signalled. An ambiguous
|
|
340
|
+
# ``OrderDispositionUnknownError`` (caught by the broader
|
|
341
|
+
# ``BrokerError`` contract, not here) leaves the close's fate —
|
|
342
|
+
# and thus a possibly-owed residual — unknown, so its breadcrumb
|
|
343
|
+
# must survive for restart reconciliation and is left intact.
|
|
344
|
+
if wrote_breadcrumb and self._store_ctx is not None:
|
|
345
|
+
clear_residual_open_row(self._store_ctx, residual_coid)
|
|
346
|
+
raise
|
|
347
|
+
opened_orders: tuple = ()
|
|
348
|
+
if plan.open_qty > 0.0:
|
|
349
|
+
try:
|
|
350
|
+
opened_orders = tuple(await port.place_leg(envelope, plan.open_qty))
|
|
351
|
+
except ExchangeOrderRejectedError:
|
|
352
|
+
# The exchange definitively refused the residual entry — nothing
|
|
353
|
+
# opened. The dispatch path turns this into a non-halting skip
|
|
354
|
+
# (signal dropped, bot continues), so the breadcrumb must be
|
|
355
|
+
# discharged here: leaving it live would let a later restart
|
|
356
|
+
# replay re-open a residual the exchange already rejected and Pine
|
|
357
|
+
# never re-signalled. Only a DEFINITIVE reject clears it — an
|
|
358
|
+
# ambiguous ``OrderDispositionUnknownError`` / connection error
|
|
359
|
+
# leaves the open's fate unknown, so its breadcrumb must survive
|
|
360
|
+
# for restart reconciliation and is left intact (re-raised).
|
|
361
|
+
if wrote_breadcrumb and self._store_ctx is not None:
|
|
362
|
+
clear_residual_open_row(self._store_ctx, residual_coid)
|
|
363
|
+
raise
|
|
364
|
+
if wrote_breadcrumb and self._store_ctx is not None:
|
|
365
|
+
# The residual entry row is now persisted (persist-first inside
|
|
366
|
+
# ``place_leg``) and dispatched — the breadcrumb is discharged.
|
|
367
|
+
clear_residual_open_row(self._store_ctx, residual_coid)
|
|
368
|
+
return ReversalFanResult(
|
|
369
|
+
closes=tuple(dispatched), open_qty=plan.open_qty,
|
|
370
|
+
opened_orders=opened_orders,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
async def _reject_unsupported_partial_closes(
|
|
375
|
+
closes: tuple[LegClose, ...], legs: list[PositionLeg], *,
|
|
376
|
+
symbol: str, intent_key: str, port: 'PositionPort',
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Atomically skip a close plan containing a partial leg slice on a
|
|
379
|
+
port that cannot express one.
|
|
380
|
+
|
|
381
|
+
A venue without a per-leg partial reduce (Capital.com's
|
|
382
|
+
``DELETE /positions/{dealId}`` is full-row only) declares
|
|
383
|
+
``supports_partial_leg_close = False`` on its port; an absent
|
|
384
|
+
attribute means supported. The check runs BEFORE any close-leg row
|
|
385
|
+
is persisted or dispatched, so the skip leaves no half-reduced book
|
|
386
|
+
and nothing for restart replay to resume — raising from inside the
|
|
387
|
+
fan (the partial tail is dispatched last, FIFO) would desync the
|
|
388
|
+
engine exactly like the volume-bounds case documented in
|
|
389
|
+
:meth:`run_reversal`. Volumes are compared on the broker grid so
|
|
390
|
+
float noise in the Pine-unit plan cannot fake a partial.
|
|
391
|
+
"""
|
|
392
|
+
if getattr(port, 'supports_partial_leg_close', True):
|
|
393
|
+
return
|
|
394
|
+
if not closes:
|
|
395
|
+
return
|
|
396
|
+
quantize = await port.get_volume_quantizer(symbol)
|
|
397
|
+
live_by_id = {leg.leg_id: leg for leg in legs}
|
|
398
|
+
for close in closes:
|
|
399
|
+
live = live_by_id.get(close.leg_id)
|
|
400
|
+
if live is None:
|
|
401
|
+
continue
|
|
402
|
+
if quantize(close.qty) < quantize(live.qty):
|
|
403
|
+
raise OrderSkippedByPlugin(
|
|
404
|
+
f"Skipping {symbol} close: leg {close.leg_id} would be "
|
|
405
|
+
f"reduced partially ({close.qty} of {live.qty}) but this "
|
|
406
|
+
f"broker only supports whole-leg closes on a hedging "
|
|
407
|
+
f"account. No order sent; partial closes need a one-way "
|
|
408
|
+
f"(netting) account.",
|
|
409
|
+
intent_key=intent_key,
|
|
410
|
+
reason="partial_leg_close_unsupported",
|
|
411
|
+
context={
|
|
412
|
+
'symbol': symbol,
|
|
413
|
+
'leg_id': close.leg_id,
|
|
414
|
+
'close_qty': close.qty,
|
|
415
|
+
'leg_qty': live.qty,
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
async def _fan_out_closes(
|
|
420
|
+
self, closes: tuple[LegClose, ...], *,
|
|
421
|
+
symbol: str, side: str, intent_key: str, pine_id: str,
|
|
422
|
+
parent_coid: str, port: 'PositionPort',
|
|
423
|
+
) -> list[tuple[str, int]]:
|
|
424
|
+
"""Snap a FIFO close plan to the broker grid and dispatch it per leg.
|
|
425
|
+
|
|
426
|
+
Shared by :meth:`run_close` and :meth:`run_reversal`. Each leg is
|
|
427
|
+
PERSIST-FIRST: its row (coid ``f"{parent_coid}:{leg_id}"``, so a restart
|
|
428
|
+
re-dispatch upserts the SAME row) is written ``pending`` BEFORE the wire
|
|
429
|
+
call and finalised ``dispatched`` only after
|
|
430
|
+
:meth:`PositionPort.close_leg` returns. Returns the
|
|
431
|
+
``(leg_id, broker-grid volume)`` pairs actually sent (empty when the
|
|
432
|
+
whole plan rounded below the grid).
|
|
433
|
+
"""
|
|
434
|
+
quantize = await port.get_volume_quantizer(symbol)
|
|
435
|
+
dispatched = plan_leg_close_volumes(closes, quantize)
|
|
436
|
+
for leg_id, volume in dispatched:
|
|
437
|
+
leg_coid = f"{parent_coid}:{leg_id}"
|
|
438
|
+
if self._store_ctx is not None:
|
|
439
|
+
create_close_leg_row(
|
|
440
|
+
self._store_ctx,
|
|
441
|
+
coid=leg_coid,
|
|
442
|
+
symbol=symbol,
|
|
443
|
+
side=side,
|
|
444
|
+
qty=float(volume),
|
|
445
|
+
intent_key=intent_key,
|
|
446
|
+
pine_entry_id=pine_id,
|
|
447
|
+
parent_close_coid=parent_coid,
|
|
448
|
+
leg_id=leg_id,
|
|
449
|
+
leg_volume=volume,
|
|
450
|
+
)
|
|
451
|
+
await port.close_leg(symbol, leg_id, volume, leg_coid)
|
|
452
|
+
if self._store_ctx is not None:
|
|
453
|
+
update_close_leg_state(
|
|
454
|
+
self._store_ctx, coid=leg_coid,
|
|
455
|
+
new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
|
|
456
|
+
)
|
|
457
|
+
return dispatched
|
|
458
|
+
|
|
459
|
+
# === Exit-bracket replication =========================================
|
|
460
|
+
|
|
461
|
+
async def run_exit_bracket(
|
|
462
|
+
self, envelope: 'DispatchEnvelope', port: 'PositionPort',
|
|
463
|
+
) -> BracketFanResult:
|
|
464
|
+
"""Replicate a Pine exit's bracket onto the net-survivor position legs.
|
|
465
|
+
|
|
466
|
+
Hedging venues carry protective levels per position, so a one-way bracket
|
|
467
|
+
over a multi-leg position is delivered by amending the SAME
|
|
468
|
+
TP/SL/trailing onto each leg that makes up the net one-way position. On a
|
|
469
|
+
mixed book (a manual hedge or a crash-interrupted reversal left opposing
|
|
470
|
+
legs open) only the legs that survive virtual-FIFO netting are protected
|
|
471
|
+
(:func:`net_survivor_legs`), never the gross majority side — amending the
|
|
472
|
+
whole majority side would close more than the net position when the stop
|
|
473
|
+
fires and flip it to the minority side. Each leg is recorded PERSIST-FIRST
|
|
474
|
+
in the ownership index (keyed by the exit's ``intent_key``) BEFORE its
|
|
475
|
+
amend, so :meth:`run_exit_bracket_clear` later clears ONLY the legs this
|
|
476
|
+
exit owns and :meth:`restart_replay` can re-assert or release them. A flat
|
|
477
|
+
symbol is a non-halting skip.
|
|
478
|
+
|
|
479
|
+
Re-running it (a modify, or a re-attach after pyramiding) upserts the
|
|
480
|
+
same per-leg rows idempotently: the row key is STABLE per (exit
|
|
481
|
+
identity, leg) — derived from the exit's pine_id + from_entry + leg_id,
|
|
482
|
+
NOT the bar-varying dispatch coid — so a modify on a later bar updates
|
|
483
|
+
the same rows instead of accreting stale ones. Two exits sharing a
|
|
484
|
+
pine_id but differing in from_entry get disjoint rows even on a shared
|
|
485
|
+
leg (the dispatch coid alone would collide — it encodes pine_id but not
|
|
486
|
+
from_entry). Legs that have since closed are released by the restart /
|
|
487
|
+
reconcile pass, not here.
|
|
488
|
+
"""
|
|
489
|
+
intent = envelope.intent
|
|
490
|
+
assert isinstance(intent, ExitIntent)
|
|
491
|
+
if intent.trail_price is not None and intent.trail_offset is not None:
|
|
492
|
+
# The PositionPort amend surface carries no ``trail_price`` — the
|
|
493
|
+
# deferred-activation trailing of the direct paths is not
|
|
494
|
+
# expressible per-leg, so the trailing arms IMMEDIATELY at
|
|
495
|
+
# ``trail_offset``. Trades can exit earlier/tighter than the Pine
|
|
496
|
+
# simulation; surface it instead of diverging silently.
|
|
497
|
+
_blog_warning(
|
|
498
|
+
"one-way bracket replication: trail_price activation is not "
|
|
499
|
+
"expressible per-leg — trailing on %s (exit %r) arms "
|
|
500
|
+
"immediately at offset %s instead of after the activation "
|
|
501
|
+
"level %s",
|
|
502
|
+
intent.symbol, intent.pine_id, intent.trail_offset,
|
|
503
|
+
intent.trail_price,
|
|
504
|
+
)
|
|
505
|
+
legs = await port.fetch_raw_positions(intent.symbol)
|
|
506
|
+
side, on_side = net_survivor_legs(legs)
|
|
507
|
+
if side == 'flat' or not on_side:
|
|
508
|
+
return BracketFanResult(legs=(), skipped=True)
|
|
509
|
+
survivors = {leg.leg_id for leg in on_side}
|
|
510
|
+
# A re-attach can narrow the survivor set: a manual hedge or an
|
|
511
|
+
# interrupted reversal opens an opposing leg that virtually FIFO-consumes
|
|
512
|
+
# the oldest majority leg, so :func:`net_survivor_legs` now drops a leg
|
|
513
|
+
# this exit owned on a prior attach. That leg is still PHYSICALLY open
|
|
514
|
+
# (only virtually netted) and still carries the broker bracket from the
|
|
515
|
+
# earlier attach; its ownership row would otherwise stay ``active`` and the
|
|
516
|
+
# restart pass would re-assert the stale stop, which on a hit closes more
|
|
517
|
+
# than the net exposure and flips the book. Clear + release those dropped
|
|
518
|
+
# legs FIRST: a survivor amend below can raise
|
|
519
|
+
# :class:`OrderDispositionUnknownError`, which the dispatch path parks
|
|
520
|
+
# while promoting the NEW intent into ``_active_intents`` — the next diff
|
|
521
|
+
# then sees Pine == active and never re-runs this fan, so a dropped-leg
|
|
522
|
+
# clear left AFTER the survivor loop would never get retried. The dropped
|
|
523
|
+
# set is disjoint from ``survivors`` (the clear skips survivor rows), so
|
|
524
|
+
# ordering it before the amend loop is otherwise behaviour-neutral.
|
|
525
|
+
await self._clear_dropped_survivor_legs(
|
|
526
|
+
envelope, intent, survivors=survivors, port=port,
|
|
527
|
+
)
|
|
528
|
+
# Bar-varying dispatch coid (matches the plugin's per-leg amend anchor);
|
|
529
|
+
# one per bracket attach, reused across its legs.
|
|
530
|
+
attach_coid = envelope.client_order_id(KIND_EXIT_SL)
|
|
531
|
+
replicated: list[str] = []
|
|
532
|
+
for leg in on_side:
|
|
533
|
+
own_coid = self._ownership_coid(intent.intent_key, leg.leg_id)
|
|
534
|
+
if self._store_ctx is not None:
|
|
535
|
+
create_bracket_ownership_row(
|
|
536
|
+
self._store_ctx,
|
|
537
|
+
coid=own_coid,
|
|
538
|
+
symbol=intent.symbol,
|
|
539
|
+
side=intent.side,
|
|
540
|
+
qty=intent.qty,
|
|
541
|
+
intent_key=intent.intent_key,
|
|
542
|
+
pine_entry_id=intent.pine_id,
|
|
543
|
+
from_entry=intent.from_entry,
|
|
544
|
+
leg_id=leg.leg_id,
|
|
545
|
+
attach_coid=attach_coid,
|
|
546
|
+
tp_price=intent.tp_price,
|
|
547
|
+
sl_price=intent.sl_price,
|
|
548
|
+
trail_price=intent.trail_price,
|
|
549
|
+
trail_offset=intent.trail_offset,
|
|
550
|
+
oca_name=intent.oca_name,
|
|
551
|
+
oca_type=intent.oca_type,
|
|
552
|
+
)
|
|
553
|
+
try:
|
|
554
|
+
await port.amend_bracket(
|
|
555
|
+
intent.symbol, leg.leg_id, side=intent.side,
|
|
556
|
+
tp_price=intent.tp_price, sl_price=intent.sl_price,
|
|
557
|
+
trail_offset=self._trail_price_distance(intent.trail_offset),
|
|
558
|
+
coid=attach_coid,
|
|
559
|
+
)
|
|
560
|
+
except ExchangeOrderRejectedError as exc:
|
|
561
|
+
# The amend was DEFINITIVELY rejected on a leg of an OPEN
|
|
562
|
+
# position (we hold ``on_side`` legs), so the position is now
|
|
563
|
+
# open and UNPROTECTED. PERSIST-FIRST wrote this leg's ownership
|
|
564
|
+
# row before the call, but a reject means we are still alive (not
|
|
565
|
+
# a crash), so release that one never-attached row synchronously
|
|
566
|
+
# — leaving it active would make a later ownership-scoped clear
|
|
567
|
+
# or restart replay believe this exit protects a leg it does not.
|
|
568
|
+
# The legs already amended earlier in this fan keep their active
|
|
569
|
+
# rows: those brackets DO exist, and the defensive close the
|
|
570
|
+
# engine issues for the wrapped error flattens the whole
|
|
571
|
+
# position. (A leg that vanished mid-fan surfaces NOT as a reject
|
|
572
|
+
# but as the port's benign no-op, so it never reaches here; an
|
|
573
|
+
# ambiguous timeout is intentionally not caught — the amend may
|
|
574
|
+
# have landed, so its row stays active for replay / reconcile.)
|
|
575
|
+
if self._store_ctx is not None:
|
|
576
|
+
update_bracket_ownership_state(
|
|
577
|
+
self._store_ctx, coid=own_coid,
|
|
578
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
579
|
+
)
|
|
580
|
+
raise BracketAttachAfterFillRejectedError(
|
|
581
|
+
f"one-way bracket attach rejected after fill "
|
|
582
|
+
f"(leg={leg.leg_id}, from_entry={intent.from_entry!r}): {exc}",
|
|
583
|
+
position_coid=f"__pyne_orphan__{intent.symbol}__{intent.from_entry}",
|
|
584
|
+
symbol=intent.symbol,
|
|
585
|
+
position_side=('buy' if intent.side == 'sell' else 'sell'),
|
|
586
|
+
qty=intent.qty,
|
|
587
|
+
position_deal_id=leg.leg_id,
|
|
588
|
+
from_entry=intent.from_entry,
|
|
589
|
+
exit_id=intent.pine_id,
|
|
590
|
+
) from exc
|
|
591
|
+
replicated.append(leg.leg_id)
|
|
592
|
+
return BracketFanResult(legs=tuple(replicated), skipped=False)
|
|
593
|
+
|
|
594
|
+
async def _clear_dropped_survivor_legs(
|
|
595
|
+
self, envelope: 'DispatchEnvelope', intent: ExitIntent, *,
|
|
596
|
+
survivors: set[str], port: 'PositionPort',
|
|
597
|
+
) -> None:
|
|
598
|
+
"""Clear + release this exit's owned legs that fell out of the survivor set.
|
|
599
|
+
|
|
600
|
+
On a re-attach the net survivor set can shrink (an opposing leg now
|
|
601
|
+
virtually FIFO-consumes a leg this exit protected before). Each such leg is
|
|
602
|
+
still physically open and still carries the prior broker bracket, so its
|
|
603
|
+
active ownership row is cleared on the broker (amend-to-None) under a clear
|
|
604
|
+
coid distinct from the attach coid, then released — mirroring
|
|
605
|
+
:meth:`run_exit_bracket_clear` for the implicit drop. A ``*_NOT_FOUND``
|
|
606
|
+
race on a vanished leg is the port's benign no-op; the restart pass would
|
|
607
|
+
otherwise re-assert the stale stop.
|
|
608
|
+
|
|
609
|
+
An ambiguous clear (:class:`OrderDispositionUnknownError`) on a dropped
|
|
610
|
+
leg must NOT abort this call: :meth:`run_exit_bracket` runs the drop FIRST,
|
|
611
|
+
before the survivor amend loop, so propagating here would skip the NEW
|
|
612
|
+
protection on the surviving legs entirely — the dispatch path then parks
|
|
613
|
+
the error while promoting the new intent into ``_active_intents``, so the
|
|
614
|
+
next diff sees Pine == active, never re-runs the fan, and the survivors
|
|
615
|
+
stay on STALE TP/SL indefinitely. The drop is persist-first ``clearing``,
|
|
616
|
+
so leaving its row in that phase lets :meth:`drain_clearing_rows` (per
|
|
617
|
+
sync) and :meth:`restart_replay` retry the idempotent re-clear; the
|
|
618
|
+
survivor amend below proceeds with the new levels. This mirrors the
|
|
619
|
+
unknown-disposition handling in :meth:`drain_clearing_rows`.
|
|
620
|
+
"""
|
|
621
|
+
if self._store_ctx is None:
|
|
622
|
+
return
|
|
623
|
+
clear_coid = envelope.client_order_id(KIND_CANCEL)
|
|
624
|
+
rows = list(iter_active_bracket_ownerships(
|
|
625
|
+
self._store_ctx, symbol=intent.symbol, from_entry=intent.from_entry,
|
|
626
|
+
))
|
|
627
|
+
for row in rows:
|
|
628
|
+
if (row.intent_key or '') != intent.intent_key:
|
|
629
|
+
continue
|
|
630
|
+
leg_id: str | None = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
|
|
631
|
+
if leg_id is None or leg_id in survivors:
|
|
632
|
+
continue
|
|
633
|
+
update_bracket_ownership_state(
|
|
634
|
+
self._store_ctx, coid=row.client_order_id,
|
|
635
|
+
new_state=BRACKET_OWN_STATE_CLEARING,
|
|
636
|
+
extras_patch={EXTRAS_KEY_BRACKET_OWN_CLEAR_COID: clear_coid},
|
|
637
|
+
)
|
|
638
|
+
try:
|
|
639
|
+
await port.amend_bracket(
|
|
640
|
+
intent.symbol, leg_id, side=row.side,
|
|
641
|
+
tp_price=None, sl_price=None, trail_offset=None,
|
|
642
|
+
coid=clear_coid,
|
|
643
|
+
)
|
|
644
|
+
except OrderDispositionUnknownError:
|
|
645
|
+
# The clear did not confirm; leave the row ``clearing`` so
|
|
646
|
+
# ``drain_clearing_rows`` / ``restart_replay`` retry the
|
|
647
|
+
# idempotent re-clear, and DO NOT abort the survivor amend
|
|
648
|
+
# below — those legs need their new protection now.
|
|
649
|
+
continue
|
|
650
|
+
update_bracket_ownership_state(
|
|
651
|
+
self._store_ctx, coid=row.client_order_id,
|
|
652
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
async def drain_clearing_rows(self, symbol: str, port: 'PositionPort') -> set[str]:
|
|
656
|
+
"""Re-clear + release any ``clearing`` bracket-ownership rows in-session.
|
|
657
|
+
|
|
658
|
+
:meth:`_clear_dropped_survivor_legs` (and :meth:`run_exit_bracket_clear`)
|
|
659
|
+
mark a row ``clearing`` PERSIST-FIRST, then amend-to-None on the broker.
|
|
660
|
+
An ambiguous :class:`OrderDispositionUnknownError` on that amend leaves the
|
|
661
|
+
row stranded in ``clearing``: the dispatch path parks the exit and promotes
|
|
662
|
+
it into ``_active_intents``, so the next diff sees Pine == active and never
|
|
663
|
+
re-runs the owning fan, and the one-way orphan sweep skips active keys —
|
|
664
|
+
the stale leg stays armed until the next restart's :meth:`restart_replay`.
|
|
665
|
+
|
|
666
|
+
This drain closes that window. The engine calls it once per sync (after the
|
|
667
|
+
orphan sweep): every live ``clearing`` row is re-cleared under its persisted
|
|
668
|
+
clear coid and released, exactly like :meth:`_replay_bracket_one`'s clearing
|
|
669
|
+
branch but per-sync rather than restart-only. A ``clearing`` row is by
|
|
670
|
+
definition marked-for-removal, NEVER wanted protection, so re-clearing it is
|
|
671
|
+
always safe even while the owning exit is still active (its survivor legs
|
|
672
|
+
carry ``active`` rows, untouched here). A vanished leg's bracket is moot —
|
|
673
|
+
release the row. Amending to None is idempotent (an already-cleared leg
|
|
674
|
+
no-ops), so a re-clear that already landed is a broker no-op. A timeout on
|
|
675
|
+
the re-clear leaves the row ``clearing`` for the next sync to retry rather
|
|
676
|
+
than halting the bot.
|
|
677
|
+
|
|
678
|
+
Returns the set of ``intent_key`` values whose LAST surviving ownership row
|
|
679
|
+
the drain released this call. The engine drops the in-memory
|
|
680
|
+
envelope/mapping for any such key whose owning exit Pine no longer emits —
|
|
681
|
+
the orphan sweep's direct-clear timeout (``continue`` before
|
|
682
|
+
``_drop_envelope``) leaves that engine state behind, and the drain
|
|
683
|
+
releasing the row here is the second half of the same retirement. Keys that
|
|
684
|
+
still carry an ``active`` row (a live exit's survivor legs) are excluded, so
|
|
685
|
+
a live bracket never loses its anchor.
|
|
686
|
+
"""
|
|
687
|
+
if self._store_ctx is None:
|
|
688
|
+
return set()
|
|
689
|
+
rows = [
|
|
690
|
+
row
|
|
691
|
+
for row in iter_active_bracket_ownerships(self._store_ctx, symbol=symbol)
|
|
692
|
+
if (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_STATE)
|
|
693
|
+
== BRACKET_OWN_STATE_CLEARING
|
|
694
|
+
]
|
|
695
|
+
if not rows:
|
|
696
|
+
return set()
|
|
697
|
+
legs = await port.fetch_raw_positions(symbol)
|
|
698
|
+
live_ids = {leg.leg_id for leg in legs}
|
|
699
|
+
released: set[str] = set()
|
|
700
|
+
for row in rows:
|
|
701
|
+
extras = row.extras or {}
|
|
702
|
+
leg_id: str | None = extras.get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
|
|
703
|
+
if leg_id is not None and leg_id in live_ids:
|
|
704
|
+
try:
|
|
705
|
+
await port.amend_bracket(
|
|
706
|
+
symbol, leg_id, side=row.side,
|
|
707
|
+
tp_price=None, sl_price=None, trail_offset=None,
|
|
708
|
+
coid=extras.get(EXTRAS_KEY_BRACKET_OWN_CLEAR_COID)
|
|
709
|
+
or row.client_order_id,
|
|
710
|
+
)
|
|
711
|
+
except OrderDispositionUnknownError:
|
|
712
|
+
# Re-clear did not confirm; leave the row ``clearing`` so the
|
|
713
|
+
# next sync retries it (idempotent amend-to-None). Halting here
|
|
714
|
+
# would strand the bot on a recoverable broker round-trip.
|
|
715
|
+
continue
|
|
716
|
+
except ExchangeConnectionError:
|
|
717
|
+
# The connection dropped mid-drain. Earlier rows in this loop
|
|
718
|
+
# may already be RELEASED (durably closed in the store) and
|
|
719
|
+
# their keys collected in ``released``; if we let the exception
|
|
720
|
+
# escape, that partial progress is lost — the caller logs +
|
|
721
|
+
# retries but never runs the envelope/mapping retirement, so a
|
|
722
|
+
# released row's stale anchor survives and a later re-emission
|
|
723
|
+
# rebuilds the same attach coid an idempotent plugin dedups,
|
|
724
|
+
# leaving the leg unprotected. Stop draining (the link is down)
|
|
725
|
+
# but RETURN what was already released so the engine retires it;
|
|
726
|
+
# this row stays ``clearing`` for the next sync to retry.
|
|
727
|
+
break
|
|
728
|
+
update_bracket_ownership_state(
|
|
729
|
+
self._store_ctx, coid=row.client_order_id,
|
|
730
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
731
|
+
)
|
|
732
|
+
if row.intent_key is not None:
|
|
733
|
+
released.add(row.intent_key)
|
|
734
|
+
# Only report a key whose every ownership row is now gone: a key still
|
|
735
|
+
# carrying an ``active`` row (a live exit's survivor legs) must keep its
|
|
736
|
+
# envelope, so the engine must not retire it.
|
|
737
|
+
if not released:
|
|
738
|
+
return released
|
|
739
|
+
still_owned = {
|
|
740
|
+
row.intent_key
|
|
741
|
+
for row in iter_active_bracket_ownerships(self._store_ctx, symbol=symbol)
|
|
742
|
+
if row.intent_key is not None
|
|
743
|
+
}
|
|
744
|
+
return released - still_owned
|
|
745
|
+
|
|
746
|
+
async def drain_residual_opens(self, symbol: str, port: 'PositionPort') -> None:
|
|
747
|
+
"""Reconcile any live reversal residual-open breadcrumb per sync (in-session).
|
|
748
|
+
|
|
749
|
+
The restart counterpart :meth:`_replay_residual_opens` reconciles these
|
|
750
|
+
breadcrumbs once at startup, but the engine latches that replay after the
|
|
751
|
+
first sync (``_one_way_replay_done``). Two paths leave a breadcrumb live
|
|
752
|
+
mid-session: :meth:`run_reversal`'s residual ``place_leg`` raising
|
|
753
|
+
:class:`OrderDispositionUnknownError` (the open's fate unknown, so the
|
|
754
|
+
breadcrumb is intentionally NOT discharged), and :meth:`_replay_residual_one`
|
|
755
|
+
hitting the same on its own re-dispatch. Without this drain such a breadcrumb
|
|
756
|
+
would wait for the NEXT process restart before being reconciled.
|
|
757
|
+
|
|
758
|
+
This closes that window. The engine calls it once per sync (alongside
|
|
759
|
+
:meth:`drain_clearing_rows`), reconciling every live breadcrumb for
|
|
760
|
+
``symbol`` through the SAME :meth:`_replay_residual_one` path restart replay
|
|
761
|
+
uses: if the residual's persist-first entry row already landed the breadcrumb
|
|
762
|
+
is simply discharged (the entry / recovery path owns the open), otherwise the
|
|
763
|
+
residual is re-opened under its deterministic entry coid (``KIND_ENTRY``, or
|
|
764
|
+
``KIND_ENTRY_STOP`` for a stop-fired reversal) — a duplicate is impossible
|
|
765
|
+
at the exchange dedup, so a per-sync re-dispatch cannot double-open. A still-ambiguous re-dispatch leaves the row live for the
|
|
766
|
+
next sync to retry rather than halting the bot, mirroring
|
|
767
|
+
:meth:`drain_clearing_rows`. A successful in-session reversal discharges its
|
|
768
|
+
own breadcrumb synchronously, so this only ever sees genuinely unresolved
|
|
769
|
+
rows. Scoped to ``symbol`` (the engine owns one symbol); a breadcrumb for
|
|
770
|
+
another symbol is that engine's drain to reconcile.
|
|
771
|
+
"""
|
|
772
|
+
if self._store_ctx is None:
|
|
773
|
+
return
|
|
774
|
+
for row in list(iter_active_residual_opens(self._store_ctx, symbol=symbol)):
|
|
775
|
+
await self._replay_residual_one(row, port)
|
|
776
|
+
|
|
777
|
+
@staticmethod
|
|
778
|
+
def _ownership_coid(intent_key: str, leg_id: str) -> str:
|
|
779
|
+
"""Stable per-(exit, leg) bracket-ownership row key.
|
|
780
|
+
|
|
781
|
+
Keyed on the exit's ``intent_key`` (pine_id + from_entry) and the broker
|
|
782
|
+
leg id, with no bar timestamp, so a modify re-attach upserts the SAME row
|
|
783
|
+
and two exits sharing a pine_id but differing in from_entry never collide
|
|
784
|
+
on a shared leg. Distinct from the bar-varying dispatch coid.
|
|
785
|
+
"""
|
|
786
|
+
return f"bo:{intent_key}:{leg_id}"
|
|
787
|
+
|
|
788
|
+
async def run_exit_bracket_clear(
|
|
789
|
+
self, envelope: 'DispatchEnvelope', port: 'PositionPort',
|
|
790
|
+
) -> ClearFanResult:
|
|
791
|
+
"""Clear ONLY the legs the cancelled exit owns (the per-leg ownership fix).
|
|
792
|
+
|
|
793
|
+
A broadcast clear amends every position-side leg, stripping a bracket a
|
|
794
|
+
DIFFERENT exit set. This consults the persisted ownership index instead:
|
|
795
|
+
it amends-to-clear only the legs whose row matches the cancel's
|
|
796
|
+
``intent_key`` (or, for a cancel-all-by-pine_id with ``from_entry`` None,
|
|
797
|
+
every row whose exit shares that pine_id), then releases each cleared
|
|
798
|
+
row. Without a persisted index (``store_ctx`` None) there is nothing to
|
|
799
|
+
clear — a benign empty result.
|
|
800
|
+
"""
|
|
801
|
+
intent = envelope.intent
|
|
802
|
+
assert isinstance(intent, CancelIntent)
|
|
803
|
+
if self._store_ctx is None:
|
|
804
|
+
return ClearFanResult(legs=())
|
|
805
|
+
cancel_coid = envelope.client_order_id(KIND_CANCEL)
|
|
806
|
+
rows = list(iter_active_bracket_ownerships(
|
|
807
|
+
self._store_ctx, symbol=intent.symbol, from_entry=intent.from_entry,
|
|
808
|
+
))
|
|
809
|
+
cleared: list[str] = []
|
|
810
|
+
for row in rows:
|
|
811
|
+
if not self._owns(row, intent):
|
|
812
|
+
continue
|
|
813
|
+
leg_id: str | None = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
|
|
814
|
+
if leg_id is None:
|
|
815
|
+
continue
|
|
816
|
+
# PERSIST-FIRST: mark the row ``clearing`` BEFORE the amend, so an
|
|
817
|
+
# ambiguous (timed-out) clear leaves a row that :meth:`restart_replay`
|
|
818
|
+
# re-CLEARS rather than re-asserting the original bracket — the
|
|
819
|
+
# close-leg ``pending`` -> ``dispatched`` two-phase applied to the
|
|
820
|
+
# clear. Without it a timed-out clear stays ``active`` and the next
|
|
821
|
+
# restart resurrects the bracket the script asked to cancel. Record the
|
|
822
|
+
# clear coid too, so the restart re-clears under the SAME clear coid —
|
|
823
|
+
# NOT the attach coid, which a coid-idempotent plugin could dedup as a
|
|
824
|
+
# repeat of the attach and swallow, leaving the bracket armed.
|
|
825
|
+
update_bracket_ownership_state(
|
|
826
|
+
self._store_ctx, coid=row.client_order_id,
|
|
827
|
+
new_state=BRACKET_OWN_STATE_CLEARING,
|
|
828
|
+
extras_patch={EXTRAS_KEY_BRACKET_OWN_CLEAR_COID: cancel_coid},
|
|
829
|
+
)
|
|
830
|
+
await port.amend_bracket(
|
|
831
|
+
intent.symbol, leg_id, side=row.side,
|
|
832
|
+
tp_price=None, sl_price=None, trail_offset=None,
|
|
833
|
+
coid=cancel_coid,
|
|
834
|
+
)
|
|
835
|
+
update_bracket_ownership_state(
|
|
836
|
+
self._store_ctx, coid=row.client_order_id,
|
|
837
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
838
|
+
)
|
|
839
|
+
cleared.append(leg_id)
|
|
840
|
+
return ClearFanResult(legs=tuple(cleared))
|
|
841
|
+
|
|
842
|
+
@staticmethod
|
|
843
|
+
def _owns(row, intent: 'CancelIntent') -> bool:
|
|
844
|
+
"""Does ``row`` belong to the exit ``intent`` cancels?
|
|
845
|
+
|
|
846
|
+
Exact ``intent_key`` match when the cancel targets one (pine_id,
|
|
847
|
+
from_entry); an ``f"{pine_id}\\0"`` prefix match for a cancel-all
|
|
848
|
+
(``from_entry`` None) that drops every exit sharing the pine_id.
|
|
849
|
+
"""
|
|
850
|
+
row_key = row.intent_key or ''
|
|
851
|
+
if intent.from_entry is not None:
|
|
852
|
+
return row_key == intent.intent_key
|
|
853
|
+
return row_key.startswith(f"{intent.pine_id}\0")
|
|
854
|
+
|
|
855
|
+
# === Restart replay ===================================================
|
|
856
|
+
|
|
857
|
+
async def restart_replay(self, port: 'PositionPort') -> None:
|
|
858
|
+
"""Resume any close-leg fan-out or bracket replication a crash interrupted.
|
|
859
|
+
|
|
860
|
+
Called once at startup. Three ordered passes: pending close-leg rows
|
|
861
|
+
are reconciled against the live legs and the residual re-dispatched (an
|
|
862
|
+
already-settled leg is never re-closed); THEN reversal residual-open
|
|
863
|
+
breadcrumbs are reconciled (the closes are now resolved, so a still-owed
|
|
864
|
+
residual is safely re-opened) and the open re-dispatched only when its
|
|
865
|
+
own entry row never landed; THEN active bracket-ownership rows are
|
|
866
|
+
re-asserted on still-open legs (idempotent on the per-leg coid) and
|
|
867
|
+
released when their leg has vanished.
|
|
868
|
+
"""
|
|
869
|
+
if self._store_ctx is None:
|
|
870
|
+
return
|
|
871
|
+
await self._replay_close_legs(port)
|
|
872
|
+
await self._replay_residual_opens(port)
|
|
873
|
+
await self._replay_bracket_ownership(port)
|
|
874
|
+
|
|
875
|
+
async def _replay_close_legs(self, port: 'PositionPort') -> None:
|
|
876
|
+
"""Reconcile + re-dispatch any pending close-leg rows (first replay pass).
|
|
877
|
+
|
|
878
|
+
For every persisted ``pending`` close-leg row, reconcile against the live
|
|
879
|
+
broker legs before re-sending: if the leg is gone, the close already
|
|
880
|
+
landed — finalise the row without dispatching; if it is still open,
|
|
881
|
+
re-dispatch only the residual (capped at the live leg size, re-snapped to
|
|
882
|
+
the grid) so an already-partly-closed leg is never over-reduced. The
|
|
883
|
+
natural-close fill dedup guards the fill side.
|
|
884
|
+
"""
|
|
885
|
+
if self._store_ctx is None:
|
|
886
|
+
return
|
|
887
|
+
pending = list(iter_active_close_legs(self._store_ctx))
|
|
888
|
+
if not pending:
|
|
889
|
+
return
|
|
890
|
+
by_symbol: dict[str, list] = {}
|
|
891
|
+
for row in pending:
|
|
892
|
+
by_symbol.setdefault(row.symbol, []).append(row)
|
|
893
|
+
for symbol, rows in by_symbol.items():
|
|
894
|
+
legs = await port.fetch_raw_positions(symbol)
|
|
895
|
+
live_by_id = {leg.leg_id: leg for leg in legs}
|
|
896
|
+
quantize = await port.get_volume_quantizer(symbol)
|
|
897
|
+
for row in rows:
|
|
898
|
+
await self._replay_one(row, symbol, live_by_id, quantize, port)
|
|
899
|
+
|
|
900
|
+
async def _replay_one(
|
|
901
|
+
self, row, symbol: str, live_by_id: dict[str, PositionLeg], quantize, port: 'PositionPort',
|
|
902
|
+
) -> None:
|
|
903
|
+
"""Reconcile + (if needed) re-dispatch one pending close-leg row."""
|
|
904
|
+
if self._store_ctx is None:
|
|
905
|
+
return
|
|
906
|
+
extras = row.extras or {}
|
|
907
|
+
leg_id: str | None = extras.get(EXTRAS_KEY_CLOSE_LEG_ID)
|
|
908
|
+
live_leg = live_by_id.get(leg_id) if leg_id is not None else None
|
|
909
|
+
if live_leg is None or leg_id is None:
|
|
910
|
+
# Leg vanished — the close landed before the crash. Finalise only.
|
|
911
|
+
update_close_leg_state(
|
|
912
|
+
self._store_ctx, coid=row.client_order_id,
|
|
913
|
+
new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
|
|
914
|
+
)
|
|
915
|
+
return
|
|
916
|
+
persisted = extras.get(EXTRAS_KEY_CLOSE_LEG_VOLUME) or 0
|
|
917
|
+
residual = min(int(persisted), quantize(live_leg.qty))
|
|
918
|
+
if residual > 0:
|
|
919
|
+
await port.close_leg(symbol, leg_id, residual, row.client_order_id)
|
|
920
|
+
update_close_leg_state(
|
|
921
|
+
self._store_ctx, coid=row.client_order_id,
|
|
922
|
+
new_state=CLOSE_LEG_STATE_DISPATCHED, close_row=True,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
async def _replay_residual_opens(self, port: 'PositionPort') -> None:
|
|
926
|
+
"""Re-dispatch any reversal residual-open a crash left un-persisted.
|
|
927
|
+
|
|
928
|
+
Runs AFTER :meth:`_replay_close_legs`, so the reversal's FIFO closes are
|
|
929
|
+
already reconciled and re-opening the residual cannot race them. For each
|
|
930
|
+
live breadcrumb: if the residual's own entry row already exists, the
|
|
931
|
+
``place_leg`` persist-first write landed and the entry journal / startup
|
|
932
|
+
recovery own it — just clear the breadcrumb; otherwise rebuild the
|
|
933
|
+
dispatch envelope and re-open the residual under the SAME deterministic
|
|
934
|
+
entry coid (``KIND_ENTRY``, or ``KIND_ENTRY_STOP`` for a stop-fired
|
|
935
|
+
reversal; a duplicate is therefore impossible at the exchange dedup),
|
|
936
|
+
then clear it.
|
|
937
|
+
"""
|
|
938
|
+
if self._store_ctx is None:
|
|
939
|
+
return
|
|
940
|
+
for row in list(iter_active_residual_opens(self._store_ctx)):
|
|
941
|
+
await self._replay_residual_one(row, port)
|
|
942
|
+
|
|
943
|
+
async def _replay_residual_one(self, row, port: 'PositionPort') -> None:
|
|
944
|
+
"""Reconcile + (if needed) re-dispatch one residual-open breadcrumb."""
|
|
945
|
+
if self._store_ctx is None:
|
|
946
|
+
return
|
|
947
|
+
extras = row.extras or {}
|
|
948
|
+
entry_coid: str | None = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID)
|
|
949
|
+
if (entry_coid is not None
|
|
950
|
+
and self._store_ctx.get_order(entry_coid) is not None):
|
|
951
|
+
# ``place_leg`` reached its persist-first entry-row write before the
|
|
952
|
+
# crash — the open is durable and owned by the entry path. Discharge.
|
|
953
|
+
clear_residual_open_row(self._store_ctx, row.client_order_id)
|
|
954
|
+
return
|
|
955
|
+
# The residual open never persisted (persist-first guarantees the entry
|
|
956
|
+
# row precedes any wire send, so its absence proves no dispatch happened —
|
|
957
|
+
# re-opening cannot double-open). The breadcrumb is written BEFORE the
|
|
958
|
+
# FIFO closes, so a crash in that window can also leave the opposing legs
|
|
959
|
+
# un-closed AND their close-leg rows un-persisted — ``_replay_close_legs``
|
|
960
|
+
# then has nothing to re-dispatch. Re-opening the residual on top of those
|
|
961
|
+
# still-live opposing legs would over-expose the book. Reconcile against
|
|
962
|
+
# the live legs first: a genuine reversal (positive residual) fully
|
|
963
|
+
# consumes the opposing exposure, so every still-live opposing leg is an
|
|
964
|
+
# owed close — FIFO-close them under the SAME deterministic parent coid
|
|
965
|
+
# (idempotent at the exchange dedup) before the residual open.
|
|
966
|
+
run_tag = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG)
|
|
967
|
+
bar_ts_ms = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS)
|
|
968
|
+
retry_seq = extras.get(EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ)
|
|
969
|
+
assert isinstance(run_tag, str)
|
|
970
|
+
assert isinstance(bar_ts_ms, int)
|
|
971
|
+
assert isinstance(retry_seq, int)
|
|
972
|
+
opposing_side = 'sell' if row.side == 'buy' else 'buy'
|
|
973
|
+
live_legs = await port.fetch_raw_positions(row.symbol)
|
|
974
|
+
owed_closes = tuple(
|
|
975
|
+
LegClose(leg_id=leg.leg_id, qty=leg.qty)
|
|
976
|
+
for leg in live_legs
|
|
977
|
+
if leg.side == opposing_side and leg.qty > 0.0
|
|
978
|
+
)
|
|
979
|
+
if owed_closes:
|
|
980
|
+
parent_coid = row.client_order_id.removesuffix(':residual')
|
|
981
|
+
await self._fan_out_closes(
|
|
982
|
+
owed_closes, symbol=row.symbol, side=row.side,
|
|
983
|
+
intent_key=row.intent_key or row.pine_entry_id or '',
|
|
984
|
+
pine_id=row.pine_entry_id or '', parent_coid=parent_coid, port=port,
|
|
985
|
+
)
|
|
986
|
+
# Quarantine gate: the reopen leg is a new-exposure dispatch. The
|
|
987
|
+
# owed FIFO closes above still ran (risk-reducing), but the reopen
|
|
988
|
+
# is withheld and the breadcrumb stays live — the committed
|
|
989
|
+
# reversal finishes on the replay after the operator restart.
|
|
990
|
+
if (self._block_exposure_reopens is not None
|
|
991
|
+
and self._block_exposure_reopens()):
|
|
992
|
+
_blog_warning(
|
|
993
|
+
"one-way residual reopen %r withheld by quarantine; "
|
|
994
|
+
"breadcrumb stays pending until the operator restart",
|
|
995
|
+
row.client_order_id,
|
|
996
|
+
)
|
|
997
|
+
return
|
|
998
|
+
# Rebuild the intent with the ORIGINAL dispatch's coid kind: a
|
|
999
|
+
# stop-fired reversal's residual went out under ``KIND_ENTRY_STOP``
|
|
1000
|
+
# (the plugin picks the kind from ``stop_fired_market``), so the
|
|
1001
|
+
# replayed ``place_leg`` must persist and dedup under that same coid —
|
|
1002
|
+
# otherwise a second ambiguous round-trip would land an entry row the
|
|
1003
|
+
# breadcrumb's ``entry_coid`` check never finds, re-opening the
|
|
1004
|
+
# residual again. The flag is recovered from the persisted coid's kind
|
|
1005
|
+
# code rather than a separate extras field — both forms carry it raw:
|
|
1006
|
+
# canonical in ``{k}{r}``, wire in the raw prefix. A wire-form coid's
|
|
1007
|
+
# length IS the minting venue's budget, so replaying it at
|
|
1008
|
+
# ``len(entry_coid)`` re-mints the byte-identical id; a canonical coid
|
|
1009
|
+
# replays under the default (identity) budget.
|
|
1010
|
+
parsed_entry = parsed_wire = None
|
|
1011
|
+
coid_max_len = CLIENT_ORDER_ID_MAX_LEN
|
|
1012
|
+
if entry_coid is not None:
|
|
1013
|
+
parsed_entry = parse_client_order_id(entry_coid)
|
|
1014
|
+
if parsed_entry is None:
|
|
1015
|
+
parsed_wire = parse_wire_client_order_id(entry_coid)
|
|
1016
|
+
if parsed_wire is not None:
|
|
1017
|
+
coid_max_len = len(entry_coid)
|
|
1018
|
+
entry_kind = (
|
|
1019
|
+
parsed_entry.kind if parsed_entry is not None
|
|
1020
|
+
else parsed_wire.kind if parsed_wire is not None
|
|
1021
|
+
else None
|
|
1022
|
+
)
|
|
1023
|
+
stop_fired = entry_kind == KIND_ENTRY_STOP
|
|
1024
|
+
envelope = DispatchEnvelope(
|
|
1025
|
+
intent=EntryIntent(
|
|
1026
|
+
pine_id=row.pine_entry_id, symbol=row.symbol, side=row.side,
|
|
1027
|
+
qty=row.qty, order_type=OrderType.MARKET,
|
|
1028
|
+
stop_fired_market=stop_fired,
|
|
1029
|
+
),
|
|
1030
|
+
run_tag=run_tag, bar_ts_ms=bar_ts_ms, retry_seq=retry_seq,
|
|
1031
|
+
coid_max_len=coid_max_len,
|
|
1032
|
+
)
|
|
1033
|
+
try:
|
|
1034
|
+
await port.place_leg(envelope, row.qty)
|
|
1035
|
+
except ExchangeOrderRejectedError:
|
|
1036
|
+
# The exchange definitively refused the residual entry — nothing
|
|
1037
|
+
# opened. ``restart_replay`` runs inside the sync startup wrapper,
|
|
1038
|
+
# which only catches ``ExchangeConnectionError`` (sync_engine), so
|
|
1039
|
+
# propagating here would abort startup and the breadcrumb would
|
|
1040
|
+
# survive un-cleared, retrying the same rejected residual on every
|
|
1041
|
+
# later sync. Discharge the breadcrumb and stop: the residual the
|
|
1042
|
+
# exchange rejected and Pine never re-signalled must not re-open.
|
|
1043
|
+
clear_residual_open_row(self._store_ctx, row.client_order_id)
|
|
1044
|
+
return
|
|
1045
|
+
except OrderDispositionUnknownError:
|
|
1046
|
+
# The residual entry's fate is unknown (ambiguous round-trip). Leave
|
|
1047
|
+
# the breadcrumb live so the next sync's replay / drain reconciles it
|
|
1048
|
+
# against the then-known leg state, but do NOT propagate — that would
|
|
1049
|
+
# abort startup on a recoverable round-trip, mirroring the bracket
|
|
1050
|
+
# re-clear path above.
|
|
1051
|
+
return
|
|
1052
|
+
clear_residual_open_row(self._store_ctx, row.client_order_id)
|
|
1053
|
+
|
|
1054
|
+
async def _replay_bracket_ownership(self, port: 'PositionPort') -> None:
|
|
1055
|
+
"""Re-assert / finish live bracket-ownership rows (second replay pass).
|
|
1056
|
+
|
|
1057
|
+
For every live ownership row: if its leg is still open, an ``active`` row
|
|
1058
|
+
is re-asserted idempotently on the same per-leg coid (the broker no-ops an
|
|
1059
|
+
unchanged amend) while a ``clearing`` row (a clear a crash interrupted) is
|
|
1060
|
+
finished by re-clearing then releasing it; if the leg has vanished, the
|
|
1061
|
+
bracket it carried is moot — release the orphan row so it leaves the live
|
|
1062
|
+
index.
|
|
1063
|
+
"""
|
|
1064
|
+
if self._store_ctx is None:
|
|
1065
|
+
return
|
|
1066
|
+
rows = list(iter_active_bracket_ownerships(self._store_ctx))
|
|
1067
|
+
if not rows:
|
|
1068
|
+
return
|
|
1069
|
+
by_symbol: dict[str, list] = {}
|
|
1070
|
+
for row in rows:
|
|
1071
|
+
by_symbol.setdefault(row.symbol, []).append(row)
|
|
1072
|
+
for symbol, srows in by_symbol.items():
|
|
1073
|
+
legs = await port.fetch_raw_positions(symbol)
|
|
1074
|
+
live_ids = {leg.leg_id for leg in legs}
|
|
1075
|
+
for row in srows:
|
|
1076
|
+
await self._replay_bracket_one(row, symbol, live_ids, port)
|
|
1077
|
+
|
|
1078
|
+
async def _replay_bracket_one(
|
|
1079
|
+
self, row, symbol: str, live_ids: set, port: 'PositionPort',
|
|
1080
|
+
) -> None:
|
|
1081
|
+
"""Re-assert / finish one bracket-ownership row, or release a vanished leg."""
|
|
1082
|
+
if self._store_ctx is None:
|
|
1083
|
+
return
|
|
1084
|
+
extras = row.extras or {}
|
|
1085
|
+
leg_id: str | None = extras.get(EXTRAS_KEY_BRACKET_OWN_LEG_ID)
|
|
1086
|
+
if leg_id is None or leg_id not in live_ids:
|
|
1087
|
+
update_bracket_ownership_state(
|
|
1088
|
+
self._store_ctx, coid=row.client_order_id,
|
|
1089
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
1090
|
+
)
|
|
1091
|
+
return
|
|
1092
|
+
if extras.get(EXTRAS_KEY_BRACKET_OWN_STATE) == BRACKET_OWN_STATE_CLEARING:
|
|
1093
|
+
# A clear interrupted before it confirmed (crash or ambiguous
|
|
1094
|
+
# timeout): finish it. Amending to None is idempotent — an already
|
|
1095
|
+
# cleared leg no-ops — so re-clear then release, NEVER re-assert the
|
|
1096
|
+
# original levels (that would resurrect a cancelled bracket). Re-clear
|
|
1097
|
+
# under the persisted clear coid (NOT the attach coid): the contract
|
|
1098
|
+
# lets a plugin dedup a repeated coid, so reusing the attach coid here
|
|
1099
|
+
# could be swallowed as a duplicate attach, leaving the bracket armed.
|
|
1100
|
+
try:
|
|
1101
|
+
await port.amend_bracket(
|
|
1102
|
+
symbol, leg_id, side=row.side,
|
|
1103
|
+
tp_price=None, sl_price=None, trail_offset=None,
|
|
1104
|
+
coid=extras.get(EXTRAS_KEY_BRACKET_OWN_CLEAR_COID)
|
|
1105
|
+
or row.client_order_id,
|
|
1106
|
+
)
|
|
1107
|
+
except OrderDispositionUnknownError:
|
|
1108
|
+
# The re-clear did not confirm. ``restart_replay`` runs inside the
|
|
1109
|
+
# sync startup wrapper, which only catches ``ExchangeConnectionError``;
|
|
1110
|
+
# propagating this sibling error would abort startup on a recoverable
|
|
1111
|
+
# ambiguous round-trip. Leave the row ``clearing`` (NOT released) so
|
|
1112
|
+
# the next ``drain_clearing_rows`` / replay retries the idempotent
|
|
1113
|
+
# re-clear, mirroring ``drain_clearing_rows``.
|
|
1114
|
+
return
|
|
1115
|
+
update_bracket_ownership_state(
|
|
1116
|
+
self._store_ctx, coid=row.client_order_id,
|
|
1117
|
+
new_state=BRACKET_OWN_STATE_RELEASED, close_row=True,
|
|
1118
|
+
)
|
|
1119
|
+
return
|
|
1120
|
+
await port.amend_bracket(
|
|
1121
|
+
symbol, leg_id, side=row.side,
|
|
1122
|
+
tp_price=extras.get(EXTRAS_KEY_BRACKET_OWN_TP),
|
|
1123
|
+
sl_price=extras.get(EXTRAS_KEY_BRACKET_OWN_SL),
|
|
1124
|
+
trail_offset=self._trail_price_distance(
|
|
1125
|
+
extras.get(EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET)
|
|
1126
|
+
),
|
|
1127
|
+
coid=extras.get(EXTRAS_KEY_BRACKET_OWN_ATTACH_COID) or row.client_order_id,
|
|
1128
|
+
)
|