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,1379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Engine-side trigger state machine for partial-quantity bracket exits.
|
|
3
|
+
|
|
4
|
+
This module owns the lifecycle of a partial-quantity TP / SL / trailing
|
|
5
|
+
bracket that the *engine* watches and fires — as opposed to a native
|
|
6
|
+
broker bracket attached to the parent ``dealId`` (covered by
|
|
7
|
+
``execute_exit``).
|
|
8
|
+
|
|
9
|
+
The state machine and the persisted leg rows in :mod:`store_helpers`
|
|
10
|
+
form one pair: the rows are the durable representation (PERSIST-FIRST),
|
|
11
|
+
the in-memory :class:`PartialBracketLeg` ledger is the working set the
|
|
12
|
+
WATCH phase reads on every WS price tick. On a clean restart the
|
|
13
|
+
ledger is rebuilt from the rows by :meth:`SoftwarePartialBracketEngine.restart_replay`.
|
|
14
|
+
|
|
15
|
+
The dispatch-side counterpart lives in
|
|
16
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._dispatch_engine_trigger_partial_bracket`:
|
|
17
|
+
it writes the leg rows, then hands them to this state machine via
|
|
18
|
+
:meth:`SoftwarePartialBracketEngine.register_legs`. After that the
|
|
19
|
+
machine owns the legs until a terminal state.
|
|
20
|
+
|
|
21
|
+
Lifecycle ownership: the state machine itself, the in-memory ledger,
|
|
22
|
+
the PERSIST-FIRST → ledger handoff, the cascade-cancel paths (OCA,
|
|
23
|
+
parent close, broker-native SL), the restart replay, and the
|
|
24
|
+
close-dispatch settling step
|
|
25
|
+
(:meth:`SoftwarePartialBracketEngine.confirm_trigger_dispatched`,
|
|
26
|
+
:meth:`SoftwarePartialBracketEngine.mark_trigger_dispatch_failed`,
|
|
27
|
+
:meth:`SoftwarePartialBracketEngine.mark_trigger_dispatch_unknown`).
|
|
28
|
+
The price-tick wiring runs from
|
|
29
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._drive_partial_bracket_triggers`
|
|
30
|
+
at the tail of every sync cycle for plugins that advertise
|
|
31
|
+
``partial_qty_bracket_exit = CapabilityLevel.SOFTWARE``: the sync
|
|
32
|
+
engine refreshes the parent snapshot, calls
|
|
33
|
+
:meth:`SoftwarePartialBracketEngine.on_price_tick`, synthesises a
|
|
34
|
+
:class:`CloseIntent` for each triggering leg, and dispatches it
|
|
35
|
+
through the regular ``_dispatch_new`` path before calling the
|
|
36
|
+
matching settling method on the state machine. See the partial-qty
|
|
37
|
+
bracket exit design dossier §3 for the full lifecycle.
|
|
38
|
+
"""
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import TYPE_CHECKING, Callable, Iterable
|
|
41
|
+
|
|
42
|
+
from pynecore.core.broker.models import OcaType
|
|
43
|
+
from pynecore.core.broker.store_helpers import (
|
|
44
|
+
EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS,
|
|
45
|
+
EXTRAS_KEY_INTENT_PARTIAL_QTY,
|
|
46
|
+
EXTRAS_KEY_LEG_KIND,
|
|
47
|
+
EXTRAS_KEY_LEG_STATE,
|
|
48
|
+
EXTRAS_KEY_OCA_GROUP,
|
|
49
|
+
EXTRAS_KEY_OCA_TYPE,
|
|
50
|
+
EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF,
|
|
51
|
+
EXTRAS_KEY_PARENT_PINE_ENTRY_ID,
|
|
52
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL,
|
|
53
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET,
|
|
54
|
+
EXTRAS_KEY_TRIGGER_LEVEL,
|
|
55
|
+
EXTRAS_KEY_TRIGGER_OFFSET,
|
|
56
|
+
LEG_KIND_SL_PARTIAL,
|
|
57
|
+
LEG_KIND_TP_PARTIAL,
|
|
58
|
+
LEG_KIND_TRAIL_PARTIAL,
|
|
59
|
+
LEG_STATE_ABORTED_PARENT_GONE,
|
|
60
|
+
LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
|
|
61
|
+
LEG_STATE_ACTIVE,
|
|
62
|
+
LEG_STATE_ARMED,
|
|
63
|
+
LEG_STATE_CANCEL_TENTATIVE,
|
|
64
|
+
LEG_STATE_CASCADED_CANCEL,
|
|
65
|
+
LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
|
|
66
|
+
LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
|
|
67
|
+
LEG_STATE_LIVE,
|
|
68
|
+
LEG_STATE_PENDING_ENTRY,
|
|
69
|
+
LEG_STATE_TRIGGERED,
|
|
70
|
+
LEG_STATE_TRIGGERED_FAILED,
|
|
71
|
+
LEG_STATE_TRIGGERED_UNKNOWN,
|
|
72
|
+
LEG_STATE_TRIGGERING,
|
|
73
|
+
iter_active_engine_trigger_partial_legs,
|
|
74
|
+
update_engine_trigger_partial_leg_state,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING:
|
|
78
|
+
from pynecore.core.broker.models import ExchangePosition
|
|
79
|
+
from pynecore.core.broker.storage import OrderRow, RunContext
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
'PartialBracketLeg',
|
|
84
|
+
'PartialBracketSafetyVerdict',
|
|
85
|
+
'SoftwarePartialBracketEngine',
|
|
86
|
+
'TickOffsetResolver',
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Composite key uniquely identifying a leg in the in-memory ledger.
|
|
91
|
+
# The third element discriminates between concurrent legs sharing the
|
|
92
|
+
# same (pine_id, from_entry) — a tp_partial / sl_partial / trail_partial
|
|
93
|
+
# trio attached to one ``strategy.exit(...)``.
|
|
94
|
+
LegKey = tuple[str, str, str]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class PartialBracketLeg:
|
|
99
|
+
"""In-memory mirror of one engine-trigger partial bracket leg row.
|
|
100
|
+
|
|
101
|
+
Mirrors the canonical extras keys in :mod:`store_helpers`; the
|
|
102
|
+
persistent representation is the authoritative one (the engine
|
|
103
|
+
reloads this struct from there on restart). Field names match
|
|
104
|
+
the ``EXTRAS_KEY_*`` constants so a future refactor that flips
|
|
105
|
+
the storage layout (e.g. dedicated SQL columns) is a one-place
|
|
106
|
+
rename.
|
|
107
|
+
|
|
108
|
+
``side`` is the CLOSE side — opposite of the parent direction —
|
|
109
|
+
because the leg's purpose is to dispatch a partial close, not a
|
|
110
|
+
fresh open.
|
|
111
|
+
"""
|
|
112
|
+
coid: str
|
|
113
|
+
symbol: str
|
|
114
|
+
pine_id: str
|
|
115
|
+
from_entry: str
|
|
116
|
+
leg_kind: str
|
|
117
|
+
leg_state: str
|
|
118
|
+
side: str
|
|
119
|
+
qty: float
|
|
120
|
+
intent_key: str
|
|
121
|
+
parent_pine_entry_id: str
|
|
122
|
+
parent_entry_dispatch_ref: str
|
|
123
|
+
intent_partial_qty: float
|
|
124
|
+
trigger_level: float | None = None
|
|
125
|
+
trigger_offset: float | None = None
|
|
126
|
+
# Trail-only fields. ``trail_activation_level`` is the absolute price
|
|
127
|
+
# the engine waits for before the trailing stop activates: while it
|
|
128
|
+
# is non-``None`` the leg is in its pre-activation phase and
|
|
129
|
+
# :meth:`SoftwarePartialBracketEngine._is_triggered` always returns
|
|
130
|
+
# ``False``. The moment the WATCH-phase price crosses this level,
|
|
131
|
+
# the engine clears the field and seeds ``trigger_level`` with the
|
|
132
|
+
# initial moving stop (``activation - offset`` for a long parent,
|
|
133
|
+
# ``activation + offset`` for a short). ``trail_activation_offset``
|
|
134
|
+
# carries the *pre*-fill distance (price units) for trail legs
|
|
135
|
+
# whose parent entry is still pending — the parent-fill handler
|
|
136
|
+
# resolves it to an absolute activation level.
|
|
137
|
+
trail_activation_level: float | None = None
|
|
138
|
+
trail_activation_offset: float | None = None
|
|
139
|
+
oca_group: str | None = None
|
|
140
|
+
oca_type: str | None = None
|
|
141
|
+
extras: dict = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def key(self) -> LegKey:
|
|
145
|
+
return self.pine_id, self.from_entry, self.leg_kind
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class PartialBracketSafetyVerdict:
|
|
150
|
+
"""Outcome of the §2.4 safety check at trigger time.
|
|
151
|
+
|
|
152
|
+
``ok=True`` means the live parent snapshot still authorises the
|
|
153
|
+
close: the position is on the expected side and has enough open
|
|
154
|
+
quantity to reduce by ``effective_close_qty`` without flipping
|
|
155
|
+
or opening. ``ok=False`` means the engine must NOT dispatch a
|
|
156
|
+
close — the parent vanished or reversed under us.
|
|
157
|
+
"""
|
|
158
|
+
ok: bool
|
|
159
|
+
reason: str
|
|
160
|
+
effective_close_qty: float
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Sign of the parent side, normalised to +1 (long) / -1 (short) — used
|
|
164
|
+
# by trigger comparisons. ``unknown`` is a guard for pending legs whose
|
|
165
|
+
# parent direction has not been observed yet.
|
|
166
|
+
_SIDE_LONG = 'long'
|
|
167
|
+
_SIDE_SHORT = 'short'
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parent_sign(side: str) -> int | None:
|
|
171
|
+
if side == _SIDE_LONG:
|
|
172
|
+
return 1
|
|
173
|
+
if side == _SIDE_SHORT:
|
|
174
|
+
return -1
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _close_side_for_parent(parent_side: str) -> str:
|
|
179
|
+
"""The CLOSE order side for a given parent direction."""
|
|
180
|
+
if parent_side == _SIDE_LONG:
|
|
181
|
+
return 'sell'
|
|
182
|
+
if parent_side == _SIDE_SHORT:
|
|
183
|
+
return 'buy'
|
|
184
|
+
raise ValueError(f"unexpected parent side: {parent_side!r}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SoftwarePartialBracketEngine:
|
|
188
|
+
"""In-memory state machine for engine-trigger partial brackets.
|
|
189
|
+
|
|
190
|
+
One instance per :class:`OrderSyncEngine`. The engine does not
|
|
191
|
+
talk to the broker directly; the sync engine performs every
|
|
192
|
+
broker-side action (close dispatch, audit emission, safety
|
|
193
|
+
snapshot) on the state machine's behalf via dedicated hooks
|
|
194
|
+
that will be wired in Slice B. This separation keeps the state
|
|
195
|
+
machine deterministic and unit-testable in isolation.
|
|
196
|
+
|
|
197
|
+
The legacy bracket leg row format (the ``tp`` / ``sl`` legs the
|
|
198
|
+
journal already manages for native brackets) is NOT touched by
|
|
199
|
+
this state machine — those rows remain owned by the plugin's
|
|
200
|
+
bracket lifecycle. The engine-trigger leg rows use the
|
|
201
|
+
dedicated :data:`~pynecore.core.broker.store_helpers.STATE_PARTIAL_BRACKET_LEG`
|
|
202
|
+
marker so the two never collide.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
store_ctx: 'RunContext | None',
|
|
208
|
+
*,
|
|
209
|
+
state_change_listener: 'Callable[[PartialBracketLeg, str | None, str], None] | None' = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self._store_ctx = store_ctx
|
|
212
|
+
# Listener fired after every leg-state mutation (register / transition).
|
|
213
|
+
# The sync engine wires this to drive the §2.6 worst-SL recompute
|
|
214
|
+
# so the broker-native fail-safe state machine sees every armed /
|
|
215
|
+
# triggered / cancelled SL leg without coupling the partial-bracket
|
|
216
|
+
# engine to the failsafe manager directly.
|
|
217
|
+
self._state_change_listener = state_change_listener
|
|
218
|
+
self._legs: dict[LegKey, PartialBracketLeg] = {}
|
|
219
|
+
# Membership index for the OCA cascade. ``oca_group`` is the
|
|
220
|
+
# private partial-exit group name the dispatch helper mints
|
|
221
|
+
# (``__partial_exit_{pine_id}_{from_entry}__``); the cascade
|
|
222
|
+
# walks all members of one group when any of them triggers.
|
|
223
|
+
self._legs_by_oca_group: dict[str, set[LegKey]] = {}
|
|
224
|
+
# Membership index keyed by Pine parent entry id, for the
|
|
225
|
+
# parent-gone / native-SL cascade paths that target every leg
|
|
226
|
+
# attached to one parent regardless of OCA grouping.
|
|
227
|
+
self._legs_by_parent: dict[tuple[str, str], set[LegKey]] = {}
|
|
228
|
+
|
|
229
|
+
# === Registration =====================================================
|
|
230
|
+
|
|
231
|
+
def register_leg(self, leg: PartialBracketLeg) -> None:
|
|
232
|
+
"""Add one freshly-persisted leg to the in-memory ledger.
|
|
233
|
+
|
|
234
|
+
Called by the sync engine after a successful
|
|
235
|
+
:func:`~pynecore.core.broker.store_helpers.create_engine_trigger_partial_leg_row`.
|
|
236
|
+
The leg must already be in :data:`LEG_STATE_ACTIVE` — terminal
|
|
237
|
+
rows are rejected because they have no business in the ledger.
|
|
238
|
+
"""
|
|
239
|
+
if leg.leg_state not in LEG_STATE_ACTIVE:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"register_leg: refuses to track leg in non-active state "
|
|
242
|
+
f"{leg.leg_state!r} (key={leg.key!r})"
|
|
243
|
+
)
|
|
244
|
+
if leg.key in self._legs:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"register_leg: leg already tracked (key={leg.key!r}, "
|
|
247
|
+
f"existing coid={self._legs[leg.key].coid!r}, "
|
|
248
|
+
f"new coid={leg.coid!r})"
|
|
249
|
+
)
|
|
250
|
+
self._legs[leg.key] = leg
|
|
251
|
+
if leg.oca_group is not None:
|
|
252
|
+
self._legs_by_oca_group.setdefault(leg.oca_group, set()).add(leg.key)
|
|
253
|
+
self._legs_by_parent.setdefault(
|
|
254
|
+
(leg.symbol, leg.from_entry), set(),
|
|
255
|
+
).add(leg.key)
|
|
256
|
+
if self._state_change_listener is not None:
|
|
257
|
+
self._state_change_listener(leg, None, leg.leg_state)
|
|
258
|
+
|
|
259
|
+
def register_legs(self, legs: Iterable[PartialBracketLeg]) -> None:
|
|
260
|
+
for leg in legs:
|
|
261
|
+
self.register_leg(leg)
|
|
262
|
+
|
|
263
|
+
# === Queries ==========================================================
|
|
264
|
+
|
|
265
|
+
def get_leg(self, key: LegKey) -> PartialBracketLeg | None:
|
|
266
|
+
return self._legs.get(key)
|
|
267
|
+
|
|
268
|
+
def iter_legs(self) -> Iterable[PartialBracketLeg]:
|
|
269
|
+
return list(self._legs.values())
|
|
270
|
+
|
|
271
|
+
def iter_legs_for_parent(
|
|
272
|
+
self, symbol: str, from_entry: str,
|
|
273
|
+
) -> list[PartialBracketLeg]:
|
|
274
|
+
keys = self._legs_by_parent.get((symbol, from_entry), set())
|
|
275
|
+
return [self._legs[k] for k in keys if k in self._legs]
|
|
276
|
+
|
|
277
|
+
def has_active_partial_bracket(
|
|
278
|
+
self, symbol: str, from_entry: str,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Whether any active engine-trigger partial leg exists for a parent.
|
|
281
|
+
|
|
282
|
+
Used by the sync engine's invariant guard at
|
|
283
|
+
``_dispatch_engine_trigger_partial_bracket`` entry: a freshly
|
|
284
|
+
dispatched native full-row bracket on the same parent must
|
|
285
|
+
not coexist with engine-trigger partial legs (§12 #4).
|
|
286
|
+
"""
|
|
287
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
288
|
+
if leg.leg_state in LEG_STATE_ACTIVE:
|
|
289
|
+
return True
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
def has_active_legs_for_intent(self, intent_key: str) -> bool:
|
|
293
|
+
"""Whether any active leg with the given ``intent_key`` is tracked.
|
|
294
|
+
|
|
295
|
+
Used by the dispatch-side guard to reject a *re-dispatch* of the
|
|
296
|
+
same :class:`~pynecore.core.broker.models.ExitIntent`. The check
|
|
297
|
+
is intent-scoped (not parent-scoped) because Pine permits
|
|
298
|
+
multiple scale-out exits under one parent — e.g. TP1 / TP2 with
|
|
299
|
+
different ``strategy.exit(id=...)`` values share the same
|
|
300
|
+
``from_entry`` but have distinct ``intent_key`` values
|
|
301
|
+
(``(pine_id, from_entry)``). A parent-wide guard would block the
|
|
302
|
+
second scale-out as a "duplicate".
|
|
303
|
+
"""
|
|
304
|
+
for leg in self._legs.values():
|
|
305
|
+
if leg.intent_key == intent_key \
|
|
306
|
+
and leg.leg_state in LEG_STATE_ACTIVE:
|
|
307
|
+
return True
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
def has_sibling_active_legs(
|
|
311
|
+
self, symbol: str, from_entry: str, exclude_intent_key: str,
|
|
312
|
+
) -> bool:
|
|
313
|
+
"""Whether any active leg exists on a parent under a DIFFERENT intent_key.
|
|
314
|
+
|
|
315
|
+
Used by the partial-bracket modify preflight to detect the
|
|
316
|
+
scale-out sibling case: parent ``from_entry`` already carries
|
|
317
|
+
another scale-out exit (TP1 / TP2 under distinct ``pine_id`` →
|
|
318
|
+
distinct ``intent_key``) whose legs would survive a cancel of
|
|
319
|
+
``exclude_intent_key``. The whole-row exit replacement issued
|
|
320
|
+
after that cancel would then hit the §12 #4 coexistence guard
|
|
321
|
+
in :meth:`OrderSyncEngine._dispatch_new`, so the caller must
|
|
322
|
+
refuse the modify before evicting the legs that would leave
|
|
323
|
+
the parent unprotected.
|
|
324
|
+
"""
|
|
325
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
326
|
+
if leg.intent_key != exclude_intent_key \
|
|
327
|
+
and leg.leg_state in LEG_STATE_ACTIVE:
|
|
328
|
+
return True
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def cancel_legs_for_intent(
|
|
332
|
+
self, intent_key: str, *, reason: str,
|
|
333
|
+
) -> list[PartialBracketLeg]:
|
|
334
|
+
"""Cancel every active leg under one ``intent_key``.
|
|
335
|
+
|
|
336
|
+
Used by the sync engine when the strategy drops or replaces a
|
|
337
|
+
partial-bracket ``ExitIntent``: the leg row dispatch is
|
|
338
|
+
engine-internal, so the standard ``execute_cancel`` /
|
|
339
|
+
``modify_exit`` broker calls do not apply. The cascade
|
|
340
|
+
transitions each leg to :data:`LEG_STATE_CASCADED_CANCEL`
|
|
341
|
+
with the supplied ``reason`` recorded in the row's audit
|
|
342
|
+
extras.
|
|
343
|
+
"""
|
|
344
|
+
cancelled: list[PartialBracketLeg] = []
|
|
345
|
+
# Snapshot first; the transition mutates the ledger.
|
|
346
|
+
targets = [
|
|
347
|
+
leg for leg in self._legs.values()
|
|
348
|
+
if leg.intent_key == intent_key
|
|
349
|
+
and leg.leg_state in LEG_STATE_ACTIVE
|
|
350
|
+
]
|
|
351
|
+
for leg in targets:
|
|
352
|
+
self._transition(
|
|
353
|
+
leg, LEG_STATE_CASCADED_CANCEL,
|
|
354
|
+
close_row=True,
|
|
355
|
+
extras_patch={'cascade_reason': reason},
|
|
356
|
+
)
|
|
357
|
+
cancelled.append(leg)
|
|
358
|
+
return cancelled
|
|
359
|
+
|
|
360
|
+
# === Pending → armed promotion =======================================
|
|
361
|
+
|
|
362
|
+
def on_parent_entry_filled(
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
symbol: str,
|
|
366
|
+
from_entry: str,
|
|
367
|
+
fill_price: float,
|
|
368
|
+
parent_side: str,
|
|
369
|
+
parent_qty: float,
|
|
370
|
+
resolver: 'TickOffsetResolver | None' = None,
|
|
371
|
+
) -> list[PartialBracketLeg]:
|
|
372
|
+
"""Promote ``pending_entry`` legs to ``armed`` on the parent fill.
|
|
373
|
+
|
|
374
|
+
Resolves ``trigger_offset`` (tick distance) into an absolute
|
|
375
|
+
``trigger_level`` and updates the persisted row. The caller
|
|
376
|
+
is the sync engine's parent-fill handler; the ``resolver``
|
|
377
|
+
encodes the ``profit_ticks`` / ``loss_ticks`` / ``trail_points_ticks``
|
|
378
|
+
→ price conversion using the symbol's ``mintick``.
|
|
379
|
+
|
|
380
|
+
Returns the legs that were promoted. The empty list is fine
|
|
381
|
+
(no pending legs for this parent).
|
|
382
|
+
"""
|
|
383
|
+
promoted: list[PartialBracketLeg] = []
|
|
384
|
+
parent_sign = _parent_sign(parent_side)
|
|
385
|
+
if parent_sign is None:
|
|
386
|
+
return promoted
|
|
387
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
388
|
+
if leg.leg_state != LEG_STATE_PENDING_ENTRY:
|
|
389
|
+
continue
|
|
390
|
+
extras_patch: dict | None = None
|
|
391
|
+
level = leg.trigger_level
|
|
392
|
+
# For trail legs we resolve the *activation* level — the
|
|
393
|
+
# leg stays in pre-activation phase until WATCH observes
|
|
394
|
+
# the price crossing it. TP / SL legs have no activation
|
|
395
|
+
# concept: ``trigger_level`` is the final price the engine
|
|
396
|
+
# watches and the resolver fills it directly.
|
|
397
|
+
if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
|
|
398
|
+
activation = leg.trail_activation_level
|
|
399
|
+
if activation is None and leg.trail_activation_offset is not None:
|
|
400
|
+
activation = (
|
|
401
|
+
fill_price + parent_sign * leg.trail_activation_offset
|
|
402
|
+
)
|
|
403
|
+
if activation is None:
|
|
404
|
+
continue
|
|
405
|
+
leg.trail_activation_level = activation
|
|
406
|
+
extras_patch = {EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: activation}
|
|
407
|
+
else:
|
|
408
|
+
if level is None and resolver is not None:
|
|
409
|
+
level = resolver.resolve(leg, fill_price=fill_price,
|
|
410
|
+
parent_sign=parent_sign)
|
|
411
|
+
if level is None:
|
|
412
|
+
continue
|
|
413
|
+
leg.trigger_level = level
|
|
414
|
+
old_state = leg.leg_state
|
|
415
|
+
leg.leg_state = LEG_STATE_ARMED
|
|
416
|
+
if self._store_ctx is not None:
|
|
417
|
+
update_engine_trigger_partial_leg_state(
|
|
418
|
+
self._store_ctx,
|
|
419
|
+
coid=leg.coid,
|
|
420
|
+
new_leg_state=LEG_STATE_ARMED,
|
|
421
|
+
trigger_level=level,
|
|
422
|
+
extras_patch=extras_patch,
|
|
423
|
+
)
|
|
424
|
+
# Fire the state-change listener so the §2.6 worst-SL
|
|
425
|
+
# recompute picks up the just-armed leg's absolute SL.
|
|
426
|
+
# Without this notification, ``_recompute_native_failsafe_for_parent``
|
|
427
|
+
# only re-walks the legs when a *later* unrelated leg event
|
|
428
|
+
# mutates state — until then the leg is invisible to the
|
|
429
|
+
# broker-native failsafe even though it is fully armed.
|
|
430
|
+
if self._state_change_listener is not None:
|
|
431
|
+
self._state_change_listener(leg, old_state, LEG_STATE_ARMED)
|
|
432
|
+
promoted.append(leg)
|
|
433
|
+
_ = parent_qty # reserved for §2.4 future qty-bookkeeping
|
|
434
|
+
return promoted
|
|
435
|
+
|
|
436
|
+
# === WATCH phase ======================================================
|
|
437
|
+
|
|
438
|
+
def on_price_tick(
|
|
439
|
+
self,
|
|
440
|
+
*,
|
|
441
|
+
symbol: str,
|
|
442
|
+
last_price: float | None,
|
|
443
|
+
bid: float | None,
|
|
444
|
+
ask: float | None,
|
|
445
|
+
parent_snapshot: 'ExchangePosition | None',
|
|
446
|
+
) -> list[PartialBracketLeg]:
|
|
447
|
+
"""Run the WATCH-phase trigger check for all armed legs.
|
|
448
|
+
|
|
449
|
+
Returns the legs that crossed their trigger this tick and
|
|
450
|
+
passed the safety check — i.e. legs in :data:`LEG_STATE_TRIGGERING`
|
|
451
|
+
that the sync engine should now dispatch a partial
|
|
452
|
+
:class:`CloseIntent` for. The close dispatch itself is the
|
|
453
|
+
sync engine's responsibility (Slice B wiring); this method
|
|
454
|
+
owns only the state-machine bookkeeping.
|
|
455
|
+
|
|
456
|
+
:param symbol: Exchange symbol whose armed legs are checked this
|
|
457
|
+
tick; legs belonging to other symbols are skipped.
|
|
458
|
+
:param last_price: Last traded price. Used for trail recompute
|
|
459
|
+
and as the default trigger comparison source.
|
|
460
|
+
:param bid: Best bid; long-side TP and short-side SL compare
|
|
461
|
+
against this once the §12 #2 quote source is resolved.
|
|
462
|
+
:param ask: Best ask; short-side TP and long-side SL compare
|
|
463
|
+
against this.
|
|
464
|
+
:param parent_snapshot: Live position snapshot for the safety
|
|
465
|
+
check. ``None`` means the engine could not refresh the
|
|
466
|
+
snapshot — every trigger is suppressed this tick (the
|
|
467
|
+
machine waits for a snapshot rather than firing blind).
|
|
468
|
+
"""
|
|
469
|
+
triggering: list[PartialBracketLeg] = []
|
|
470
|
+
if parent_snapshot is None:
|
|
471
|
+
return triggering
|
|
472
|
+
# Same-tick reservation: when more than one armed leg under the
|
|
473
|
+
# same (symbol, from_entry) crosses on the same tick, each call
|
|
474
|
+
# to :meth:`_safety_check` would otherwise cap against the
|
|
475
|
+
# unchanged ``parent.size``. The loop decrements the remaining
|
|
476
|
+
# parent size as legs are accepted so two 0.75 legs on a 1.0
|
|
477
|
+
# position cannot collectively dispatch 1.5 and flip the parent.
|
|
478
|
+
remaining_by_parent: dict[tuple[str, str], float] = {}
|
|
479
|
+
# OCA groups that already produced a triggering leg in *this*
|
|
480
|
+
# tick. Used to skip incompatible siblings (e.g. TP + SL crossing
|
|
481
|
+
# together on a gap) without cancelling them: cancelling SL/trail
|
|
482
|
+
# legs synchronously here would fire the §2.6 worst-SL recompute
|
|
483
|
+
# listener and drop the broker-native stop *before* the
|
|
484
|
+
# triggered TP's CLOSE has been accepted/filled. If the close
|
|
485
|
+
# fails, times out, or the process crashes between detection and
|
|
486
|
+
# dispatch, the parent would be left with no software *and* no
|
|
487
|
+
# native protection. Keep the siblings ``armed`` instead; the
|
|
488
|
+
# cascade cancel runs later, on the ``triggering → triggered``
|
|
489
|
+
# transition once the close is confirmed (Slice B dispatch
|
|
490
|
+
# wiring) or on the parent-flat observation.
|
|
491
|
+
reserved_oca_groups: set[str] = set()
|
|
492
|
+
# Cross-tick reservation: a sibling already in ``triggering`` /
|
|
493
|
+
# ``triggered_failed`` / ``triggered_unknown`` from an earlier
|
|
494
|
+
# tick still has an in-flight close — the cascade cancel runs
|
|
495
|
+
# only on the ``triggering → triggered`` confirmation. Until
|
|
496
|
+
# then, another armed sibling in the same OCA group must not be
|
|
497
|
+
# allowed to cross and dispatch a second close (e.g. TP close
|
|
498
|
+
# pending, then price reverses into the SL/trail level on the
|
|
499
|
+
# next tick): that would over-reduce the parent before the
|
|
500
|
+
# first close resolves. Seed the reservation set so the
|
|
501
|
+
# per-iteration skip below covers in-flight siblings too.
|
|
502
|
+
for leg in self._legs.values():
|
|
503
|
+
if leg.symbol != symbol:
|
|
504
|
+
continue
|
|
505
|
+
if leg.oca_group is None:
|
|
506
|
+
continue
|
|
507
|
+
if leg.leg_state in (
|
|
508
|
+
LEG_STATE_TRIGGERING,
|
|
509
|
+
LEG_STATE_TRIGGERED_FAILED,
|
|
510
|
+
LEG_STATE_TRIGGERED_UNKNOWN,
|
|
511
|
+
):
|
|
512
|
+
reserved_oca_groups.add(leg.oca_group)
|
|
513
|
+
for leg in list(self._legs.values()):
|
|
514
|
+
if leg.symbol != symbol:
|
|
515
|
+
continue
|
|
516
|
+
if leg.leg_state != LEG_STATE_ARMED:
|
|
517
|
+
continue
|
|
518
|
+
if (leg.oca_group is not None
|
|
519
|
+
and leg.oca_group in reserved_oca_groups):
|
|
520
|
+
# Sibling in the same OCA group already crossed this
|
|
521
|
+
# tick; skip without cancelling so SL protection stays
|
|
522
|
+
# in place until the winning leg's close is confirmed.
|
|
523
|
+
continue
|
|
524
|
+
# Trail legs are armed *before* activation and intentionally
|
|
525
|
+
# carry ``trigger_level=None`` until the price crosses
|
|
526
|
+
# ``trail_activation_level``. Run :meth:`_maybe_advance_trail`
|
|
527
|
+
# first so a pre-activation tick can seed the initial stop;
|
|
528
|
+
# only after that does a missing ``trigger_level`` mean the
|
|
529
|
+
# leg has nothing to compare against this tick (still
|
|
530
|
+
# pre-activation, or non-trail leg without a resolved level).
|
|
531
|
+
if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
|
|
532
|
+
self._maybe_advance_trail(leg, last_price)
|
|
533
|
+
if leg.trigger_level is None:
|
|
534
|
+
continue
|
|
535
|
+
if not self._is_triggered(leg, last_price=last_price,
|
|
536
|
+
bid=bid, ask=ask):
|
|
537
|
+
continue
|
|
538
|
+
parent_key = (leg.symbol, leg.from_entry)
|
|
539
|
+
remaining = remaining_by_parent.get(parent_key, parent_snapshot.size)
|
|
540
|
+
verdict = self._safety_check(leg, parent_snapshot,
|
|
541
|
+
remaining_size=remaining)
|
|
542
|
+
if not verdict.ok:
|
|
543
|
+
self._transition(
|
|
544
|
+
leg, LEG_STATE_ABORTED_PARENT_GONE,
|
|
545
|
+
close_row=True,
|
|
546
|
+
extras_patch={'safety_abort_reason': verdict.reason},
|
|
547
|
+
)
|
|
548
|
+
continue
|
|
549
|
+
remaining_by_parent[parent_key] = max(
|
|
550
|
+
0.0, remaining - verdict.effective_close_qty,
|
|
551
|
+
)
|
|
552
|
+
self._transition(leg, LEG_STATE_TRIGGERING, close_row=False,
|
|
553
|
+
qty=verdict.effective_close_qty)
|
|
554
|
+
triggering.append(leg)
|
|
555
|
+
if leg.oca_group is not None:
|
|
556
|
+
reserved_oca_groups.add(leg.oca_group)
|
|
557
|
+
return triggering
|
|
558
|
+
|
|
559
|
+
# noinspection PyMethodMayBeStatic
|
|
560
|
+
def _is_triggered(
|
|
561
|
+
self,
|
|
562
|
+
leg: PartialBracketLeg,
|
|
563
|
+
*,
|
|
564
|
+
last_price: float | None,
|
|
565
|
+
bid: float | None,
|
|
566
|
+
ask: float | None,
|
|
567
|
+
) -> bool:
|
|
568
|
+
"""Whether the current quote crosses the leg's trigger level.
|
|
569
|
+
|
|
570
|
+
The bid/ask vs. last-price selection is intentionally simple
|
|
571
|
+
in Slice A and defers to ``last_price`` whenever it is
|
|
572
|
+
available; §12 #2 will refine the choice once the WS quote
|
|
573
|
+
feed semantics are validated in production. The comparison
|
|
574
|
+
directions:
|
|
575
|
+
|
|
576
|
+
- Long parent, TP leg: trigger when price >= level.
|
|
577
|
+
- Long parent, SL / trail leg: trigger when price <= level.
|
|
578
|
+
- Short parent, TP leg: trigger when price <= level.
|
|
579
|
+
- Short parent, SL / trail leg: trigger when price >= level.
|
|
580
|
+
|
|
581
|
+
Trail legs whose ``trail_activation_level`` is still set
|
|
582
|
+
return ``False`` here — the leg is armed but the trailing
|
|
583
|
+
stop has not yet "activated" so the trigger comparison is
|
|
584
|
+
meaningless. Activation itself is handled by
|
|
585
|
+
:meth:`_maybe_advance_trail`, which clears the activation
|
|
586
|
+
field and seeds ``trigger_level`` once the price crosses the
|
|
587
|
+
activation threshold.
|
|
588
|
+
"""
|
|
589
|
+
if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL \
|
|
590
|
+
and leg.trail_activation_level is not None:
|
|
591
|
+
return False
|
|
592
|
+
if leg.trigger_level is None:
|
|
593
|
+
return False
|
|
594
|
+
price = last_price if last_price is not None else (
|
|
595
|
+
bid if bid is not None else ask
|
|
596
|
+
)
|
|
597
|
+
if price is None:
|
|
598
|
+
return False
|
|
599
|
+
parent_long = leg.side == 'sell'
|
|
600
|
+
is_take_profit = leg.leg_kind == LEG_KIND_TP_PARTIAL
|
|
601
|
+
if parent_long:
|
|
602
|
+
return (price >= leg.trigger_level) if is_take_profit \
|
|
603
|
+
else (price <= leg.trigger_level)
|
|
604
|
+
return (price <= leg.trigger_level) if is_take_profit \
|
|
605
|
+
else (price >= leg.trigger_level)
|
|
606
|
+
|
|
607
|
+
def _maybe_advance_trail(
|
|
608
|
+
self,
|
|
609
|
+
leg: PartialBracketLeg,
|
|
610
|
+
last_price: float | None,
|
|
611
|
+
) -> None:
|
|
612
|
+
"""Drive a trailing-stop leg through activation and the trail itself.
|
|
613
|
+
|
|
614
|
+
Two-phase contract:
|
|
615
|
+
|
|
616
|
+
1. **Pre-activation** (``trail_activation_level`` set): the
|
|
617
|
+
method waits for ``last_price`` to cross the activation
|
|
618
|
+
threshold in the favourable direction (>= for a long parent,
|
|
619
|
+
<= for a short). When it does, it clears
|
|
620
|
+
``trail_activation_level`` and seeds ``trigger_level`` with
|
|
621
|
+
the initial moving stop (``activation - offset`` long /
|
|
622
|
+
``activation + offset`` short). The leg now enters the
|
|
623
|
+
normal trail phase and the next price tick may already
|
|
624
|
+
trigger via :meth:`_is_triggered`.
|
|
625
|
+
2. **Active trail** (``trail_activation_level`` is ``None``):
|
|
626
|
+
the trigger level only moves in the favourable direction —
|
|
627
|
+
a long parent trail can only rise, a short can only fall.
|
|
628
|
+
|
|
629
|
+
Both transitions persist the new ``trigger_level`` /
|
|
630
|
+
``trail_activation_level`` so a restart mid-trail does not lose
|
|
631
|
+
the high-water mark or the activation state.
|
|
632
|
+
"""
|
|
633
|
+
if leg.leg_kind != LEG_KIND_TRAIL_PARTIAL:
|
|
634
|
+
return
|
|
635
|
+
if last_price is None or leg.trigger_offset is None:
|
|
636
|
+
return
|
|
637
|
+
parent_long = leg.side == 'sell'
|
|
638
|
+
if leg.trail_activation_level is not None:
|
|
639
|
+
# Pre-activation: wait for the activation threshold.
|
|
640
|
+
activation = leg.trail_activation_level
|
|
641
|
+
activated = (last_price >= activation) if parent_long \
|
|
642
|
+
else (last_price <= activation)
|
|
643
|
+
if not activated:
|
|
644
|
+
return
|
|
645
|
+
# Seed the initial stop from the current favourable price,
|
|
646
|
+
# not from the activation threshold. When the tick jumps
|
|
647
|
+
# past activation (e.g. activation=100, last_price=110,
|
|
648
|
+
# offset=5 on a long), the initial stop must be
|
|
649
|
+
# ``last_price - offset = 105`` so the trail starts as
|
|
650
|
+
# tight as the offset prescribes. Anchoring on activation
|
|
651
|
+
# would leave the stop at 95 — too loose by the entire
|
|
652
|
+
# over-shoot — until another favourable tick arrived.
|
|
653
|
+
initial_stop = (last_price - leg.trigger_offset) if parent_long \
|
|
654
|
+
else (last_price + leg.trigger_offset)
|
|
655
|
+
leg.trail_activation_level = None
|
|
656
|
+
leg.trigger_level = initial_stop
|
|
657
|
+
if self._store_ctx is not None:
|
|
658
|
+
update_engine_trigger_partial_leg_state(
|
|
659
|
+
self._store_ctx,
|
|
660
|
+
coid=leg.coid,
|
|
661
|
+
new_leg_state=leg.leg_state,
|
|
662
|
+
trigger_level=initial_stop,
|
|
663
|
+
extras_patch={EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: None},
|
|
664
|
+
)
|
|
665
|
+
if self._state_change_listener is not None:
|
|
666
|
+
# No state transition — but the trail leg's worst-SL
|
|
667
|
+
# contribution just changed (pre-activation watch level
|
|
668
|
+
# → post-activation trailing stop). Fire so the §2.6
|
|
669
|
+
# failsafe manager recomputes with the new level.
|
|
670
|
+
self._state_change_listener(leg, leg.leg_state, leg.leg_state)
|
|
671
|
+
return
|
|
672
|
+
candidate = (last_price - leg.trigger_offset) if parent_long \
|
|
673
|
+
else (last_price + leg.trigger_offset)
|
|
674
|
+
current = leg.trigger_level
|
|
675
|
+
if current is None:
|
|
676
|
+
new_level = candidate
|
|
677
|
+
elif parent_long and candidate > current:
|
|
678
|
+
new_level = candidate
|
|
679
|
+
elif (not parent_long) and candidate < current:
|
|
680
|
+
new_level = candidate
|
|
681
|
+
else:
|
|
682
|
+
return
|
|
683
|
+
leg.trigger_level = new_level
|
|
684
|
+
if self._store_ctx is not None:
|
|
685
|
+
update_engine_trigger_partial_leg_state(
|
|
686
|
+
self._store_ctx,
|
|
687
|
+
coid=leg.coid,
|
|
688
|
+
new_leg_state=leg.leg_state,
|
|
689
|
+
trigger_level=new_level,
|
|
690
|
+
)
|
|
691
|
+
if self._state_change_listener is not None:
|
|
692
|
+
# Trail leg moved its trigger_level while staying armed —
|
|
693
|
+
# the §2.6 failsafe manager needs the new contribution.
|
|
694
|
+
self._state_change_listener(leg, leg.leg_state, leg.leg_state)
|
|
695
|
+
|
|
696
|
+
# noinspection PyMethodMayBeStatic
|
|
697
|
+
def _safety_check(
|
|
698
|
+
self,
|
|
699
|
+
leg: PartialBracketLeg,
|
|
700
|
+
parent: 'ExchangePosition',
|
|
701
|
+
*,
|
|
702
|
+
remaining_size: float | None = None,
|
|
703
|
+
) -> PartialBracketSafetyVerdict:
|
|
704
|
+
"""§2.4 invariant: parent on the expected side and large enough.
|
|
705
|
+
|
|
706
|
+
The leg's recorded ``side`` is the CLOSE side: ``'sell'`` means
|
|
707
|
+
the leg closes a long parent. If the live snapshot disagrees
|
|
708
|
+
(parent flat, parent on the other side), the leg must abort —
|
|
709
|
+
firing the close now could reopen a position in the wrong
|
|
710
|
+
direction. The qty cap prevents over-reduction when the parent
|
|
711
|
+
was partially closed by a different actor.
|
|
712
|
+
|
|
713
|
+
:param remaining_size: When supplied, caps ``effective_close_qty``
|
|
714
|
+
against this value instead of the raw ``parent.size``. The
|
|
715
|
+
same-tick caller decrements the remaining parent size as
|
|
716
|
+
sibling legs are accepted so two crossings on one tick cannot
|
|
717
|
+
collectively close more than the live parent holds.
|
|
718
|
+
"""
|
|
719
|
+
if parent.side == 'flat' or parent.size <= 0.0:
|
|
720
|
+
return PartialBracketSafetyVerdict(
|
|
721
|
+
ok=False,
|
|
722
|
+
reason='parent_flat',
|
|
723
|
+
effective_close_qty=0.0,
|
|
724
|
+
)
|
|
725
|
+
expected_parent_side = _SIDE_LONG if leg.side == 'sell' else _SIDE_SHORT
|
|
726
|
+
if parent.side != expected_parent_side:
|
|
727
|
+
return PartialBracketSafetyVerdict(
|
|
728
|
+
ok=False,
|
|
729
|
+
reason='parent_reversed',
|
|
730
|
+
effective_close_qty=0.0,
|
|
731
|
+
)
|
|
732
|
+
size_cap = parent.size if remaining_size is None \
|
|
733
|
+
else min(parent.size, remaining_size)
|
|
734
|
+
# ``leg.qty`` is the per-leg cap that survives the row's lifecycle:
|
|
735
|
+
# at first arming it equals ``intent_partial_qty``, but the
|
|
736
|
+
# same-tick sibling cascade in :meth:`on_price_tick` rewrites it
|
|
737
|
+
# via :meth:`_transition` with the smaller ``effective_close_qty``
|
|
738
|
+
# before the close dispatches. If the process crashed between the
|
|
739
|
+
# cap write and the close fill, :meth:`restart_replay` demotes the
|
|
740
|
+
# leg back to ``armed`` but keeps the persisted capped value;
|
|
741
|
+
# falling back to ``intent_partial_qty`` here would let the retry
|
|
742
|
+
# close the full original size and over-reduce a parent that was
|
|
743
|
+
# already partially closed by the prior cap's settled fill.
|
|
744
|
+
desired_qty = min(leg.qty, leg.intent_partial_qty) \
|
|
745
|
+
if leg.qty > 0.0 else leg.intent_partial_qty
|
|
746
|
+
effective_qty = min(desired_qty, size_cap)
|
|
747
|
+
if effective_qty <= 0.0:
|
|
748
|
+
return PartialBracketSafetyVerdict(
|
|
749
|
+
ok=False,
|
|
750
|
+
reason='zero_effective_qty',
|
|
751
|
+
effective_close_qty=0.0,
|
|
752
|
+
)
|
|
753
|
+
return PartialBracketSafetyVerdict(
|
|
754
|
+
ok=True,
|
|
755
|
+
reason='ok',
|
|
756
|
+
effective_close_qty=effective_qty,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# === Close-dispatch settling ==========================================
|
|
760
|
+
|
|
761
|
+
def confirm_trigger_dispatched(
|
|
762
|
+
self,
|
|
763
|
+
leg_key: LegKey,
|
|
764
|
+
*,
|
|
765
|
+
close_pine_id: str,
|
|
766
|
+
) -> list[PartialBracketLeg]:
|
|
767
|
+
"""Settle a :data:`LEG_STATE_TRIGGERING` leg as fired.
|
|
768
|
+
|
|
769
|
+
Called by the sync engine immediately after the synthetic
|
|
770
|
+
:class:`CloseIntent` for ``leg`` has been dispatched to the
|
|
771
|
+
broker plugin. The leg moves to
|
|
772
|
+
:data:`LEG_STATE_TRIGGERED` (terminal, row closed) and the
|
|
773
|
+
OCA cascade fires for sibling legs sharing the same
|
|
774
|
+
:attr:`PartialBracketLeg.oca_group` with
|
|
775
|
+
:attr:`OcaType.CANCEL` semantics. ``close_pine_id`` is
|
|
776
|
+
the synthesised exit id stamped on the close envelope so the
|
|
777
|
+
cascade audit row can correlate the two.
|
|
778
|
+
|
|
779
|
+
:return: List of OCA siblings that were cascaded by this
|
|
780
|
+
settlement; empty if the leg has no OCA group or no
|
|
781
|
+
cancellable sibling. The triggered leg itself is not
|
|
782
|
+
included.
|
|
783
|
+
"""
|
|
784
|
+
leg = self._legs.get(leg_key)
|
|
785
|
+
if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
|
|
786
|
+
return []
|
|
787
|
+
# Cascade FIRST while the triggered leg is still in the ledger —
|
|
788
|
+
# :meth:`cascade_cancel_oca` resolves the OCA group via
|
|
789
|
+
# ``self._legs[triggered_key]``, so a prior eviction would silently
|
|
790
|
+
# drop the cascade. The triggered leg's own state stays
|
|
791
|
+
# ``triggering`` for this call (siblings already in a terminal
|
|
792
|
+
# state are skipped by the cascade), and the final transition
|
|
793
|
+
# below flips it to :data:`LEG_STATE_TRIGGERED` and closes the row.
|
|
794
|
+
cascaded = self.cascade_cancel_oca(leg_key)
|
|
795
|
+
self._transition(
|
|
796
|
+
leg, LEG_STATE_TRIGGERED,
|
|
797
|
+
close_row=True,
|
|
798
|
+
extras_patch={'close_pine_id': close_pine_id},
|
|
799
|
+
)
|
|
800
|
+
return cascaded
|
|
801
|
+
|
|
802
|
+
def mark_trigger_dispatch_failed(
|
|
803
|
+
self,
|
|
804
|
+
leg_key: LegKey,
|
|
805
|
+
*,
|
|
806
|
+
reason: str,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Settle a :data:`LEG_STATE_TRIGGERING` leg as a hard failure.
|
|
809
|
+
|
|
810
|
+
Used when the close dispatch was rejected with an error the
|
|
811
|
+
engine cannot reasonably retry on its own (e.g.
|
|
812
|
+
:class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`
|
|
813
|
+
or a non-recoverable broker error that is NOT a network
|
|
814
|
+
ambiguity). The leg lands briefly in
|
|
815
|
+
:data:`LEG_STATE_TRIGGERED_FAILED` for the audit row, then
|
|
816
|
+
demotes back to :data:`LEG_STATE_ARMED` so the next price
|
|
817
|
+
tick re-evaluates against a fresh parent snapshot. Idempotent
|
|
818
|
+
no-op when the leg is not in :data:`LEG_STATE_TRIGGERING`.
|
|
819
|
+
"""
|
|
820
|
+
leg = self._legs.get(leg_key)
|
|
821
|
+
if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
|
|
822
|
+
return
|
|
823
|
+
self._transition(
|
|
824
|
+
leg, LEG_STATE_TRIGGERED_FAILED,
|
|
825
|
+
close_row=False,
|
|
826
|
+
extras_patch={'trigger_failed_reason': reason},
|
|
827
|
+
)
|
|
828
|
+
self._transition(
|
|
829
|
+
leg, LEG_STATE_ARMED,
|
|
830
|
+
close_row=False,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def mark_trigger_dispatch_unknown(
|
|
834
|
+
self,
|
|
835
|
+
leg_key: LegKey,
|
|
836
|
+
*,
|
|
837
|
+
reason: str,
|
|
838
|
+
) -> None:
|
|
839
|
+
"""Settle a :data:`LEG_STATE_TRIGGERING` leg as a parked dispatch.
|
|
840
|
+
|
|
841
|
+
Used when the close dispatch raised
|
|
842
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
|
|
843
|
+
— the broker may or may not have accepted the order. The leg
|
|
844
|
+
lands briefly in :data:`LEG_STATE_TRIGGERED_UNKNOWN` for the
|
|
845
|
+
audit row, then demotes back to :data:`LEG_STATE_ARMED` so the
|
|
846
|
+
next price tick re-evaluates the trigger. The sync engine's
|
|
847
|
+
regular parked-dispatch resolution path (``_verify_pending_dispatches``)
|
|
848
|
+
will reconcile any in-flight close that did in fact land — the
|
|
849
|
+
re-armed leg's :meth:`_safety_check` caps against the live
|
|
850
|
+
parent size and avoids over-closing if the prior attempt
|
|
851
|
+
already reduced the position. Idempotent no-op when the leg
|
|
852
|
+
is not in :data:`LEG_STATE_TRIGGERING`.
|
|
853
|
+
"""
|
|
854
|
+
leg = self._legs.get(leg_key)
|
|
855
|
+
if leg is None or leg.leg_state != LEG_STATE_TRIGGERING:
|
|
856
|
+
return
|
|
857
|
+
self._transition(
|
|
858
|
+
leg, LEG_STATE_TRIGGERED_UNKNOWN,
|
|
859
|
+
close_row=False,
|
|
860
|
+
extras_patch={'trigger_unknown_reason': reason},
|
|
861
|
+
)
|
|
862
|
+
self._transition(
|
|
863
|
+
leg, LEG_STATE_ARMED,
|
|
864
|
+
close_row=False,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
# === OCA cascade ======================================================
|
|
868
|
+
|
|
869
|
+
def cascade_cancel_oca(
|
|
870
|
+
self,
|
|
871
|
+
triggered_key: LegKey,
|
|
872
|
+
*,
|
|
873
|
+
reason: str = 'oca_sibling_triggered',
|
|
874
|
+
) -> list[PartialBracketLeg]:
|
|
875
|
+
"""Cancel every armed sibling sharing the triggered leg's OCA group.
|
|
876
|
+
|
|
877
|
+
Only :data:`OcaType.CANCEL` groups cascade — ``reduce`` and
|
|
878
|
+
``none`` groups leave their siblings alone, matching the
|
|
879
|
+
sync-engine OCA path (:meth:`OrderSyncEngine._cascade_oca_cancel`)
|
|
880
|
+
which gates on ``oca_type == OcaType.CANCEL.value``. Keying solely
|
|
881
|
+
on ``oca_group`` here would otherwise tear down unrelated
|
|
882
|
+
``reduce``/``none`` siblings as soon as one leg triggers, dropping
|
|
883
|
+
partial-exit protection the script intentionally kept independent.
|
|
884
|
+
|
|
885
|
+
The triggered leg itself is left alone (the caller flips it to
|
|
886
|
+
:data:`~pynecore.core.broker.store_helpers.LEG_STATE_TRIGGERED`
|
|
887
|
+
after the close fill). Siblings already in a terminal state
|
|
888
|
+
are skipped.
|
|
889
|
+
"""
|
|
890
|
+
triggered = self._legs.get(triggered_key)
|
|
891
|
+
if triggered is None or triggered.oca_group is None:
|
|
892
|
+
return []
|
|
893
|
+
if triggered.oca_type != OcaType.CANCEL.value:
|
|
894
|
+
return []
|
|
895
|
+
cancelled: list[PartialBracketLeg] = []
|
|
896
|
+
for key in list(self._legs_by_oca_group.get(triggered.oca_group, ())):
|
|
897
|
+
if key == triggered_key:
|
|
898
|
+
continue
|
|
899
|
+
sibling = self._legs.get(key)
|
|
900
|
+
if sibling is None or sibling.leg_state not in LEG_STATE_ACTIVE:
|
|
901
|
+
continue
|
|
902
|
+
if sibling.oca_type != OcaType.CANCEL.value:
|
|
903
|
+
# Mixed-type groupings are degenerate (a single oca_group
|
|
904
|
+
# name should carry one consistent oca_type), but skip
|
|
905
|
+
# defensively rather than cancel a ``reduce``/``none``
|
|
906
|
+
# sibling that the script wanted to keep alive.
|
|
907
|
+
continue
|
|
908
|
+
self._transition(
|
|
909
|
+
sibling, LEG_STATE_CASCADED_CANCEL,
|
|
910
|
+
close_row=True,
|
|
911
|
+
extras_patch={'cascade_reason': reason},
|
|
912
|
+
)
|
|
913
|
+
cancelled.append(sibling)
|
|
914
|
+
return cancelled
|
|
915
|
+
|
|
916
|
+
def cascade_cancel_by_parent_close(
|
|
917
|
+
self,
|
|
918
|
+
*,
|
|
919
|
+
symbol: str,
|
|
920
|
+
from_entry: str,
|
|
921
|
+
reason: str = 'parent_closed',
|
|
922
|
+
) -> list[PartialBracketLeg]:
|
|
923
|
+
"""Cancel every active leg under a parent that just flattened."""
|
|
924
|
+
cancelled: list[PartialBracketLeg] = []
|
|
925
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
926
|
+
if leg.leg_state not in LEG_STATE_ACTIVE:
|
|
927
|
+
continue
|
|
928
|
+
self._transition(
|
|
929
|
+
leg, LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
|
|
930
|
+
close_row=True,
|
|
931
|
+
extras_patch={'cascade_reason': reason},
|
|
932
|
+
)
|
|
933
|
+
cancelled.append(leg)
|
|
934
|
+
return cancelled
|
|
935
|
+
|
|
936
|
+
def cascade_cancel_by_native_sl(
|
|
937
|
+
self,
|
|
938
|
+
*,
|
|
939
|
+
symbol: str,
|
|
940
|
+
from_entry: str,
|
|
941
|
+
sl_level: float,
|
|
942
|
+
) -> list[PartialBracketLeg]:
|
|
943
|
+
"""§3.4: a broker-native fail-safe SL hit replaces every active leg.
|
|
944
|
+
|
|
945
|
+
The snapshot-driven reconcile in the sync engine observes
|
|
946
|
+
the position vanishing; every remaining engine-trigger leg
|
|
947
|
+
(intermediate SL, TP, trail) goes to
|
|
948
|
+
:data:`~pynecore.core.broker.store_helpers.LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL`
|
|
949
|
+
with the SL level recorded for audit.
|
|
950
|
+
"""
|
|
951
|
+
cancelled: list[PartialBracketLeg] = []
|
|
952
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
953
|
+
if leg.leg_state not in LEG_STATE_ACTIVE:
|
|
954
|
+
continue
|
|
955
|
+
self._transition(
|
|
956
|
+
leg, LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
|
|
957
|
+
close_row=True,
|
|
958
|
+
extras_patch={
|
|
959
|
+
'cascade_reason': 'native_sl_hit',
|
|
960
|
+
'native_sl_level': sl_level,
|
|
961
|
+
},
|
|
962
|
+
)
|
|
963
|
+
cancelled.append(leg)
|
|
964
|
+
return cancelled
|
|
965
|
+
|
|
966
|
+
def abort_pending_legs_for_parent_never_arrived(
|
|
967
|
+
self,
|
|
968
|
+
*,
|
|
969
|
+
symbol: str,
|
|
970
|
+
from_entry: str,
|
|
971
|
+
reason: str,
|
|
972
|
+
) -> list[PartialBracketLeg]:
|
|
973
|
+
"""§3.2 cleanup: pending entry never filled (rejected / cancelled / expired).
|
|
974
|
+
|
|
975
|
+
Only ``pending_entry`` legs are affected — already-armed legs
|
|
976
|
+
belong to a different lifecycle path and would have aborted
|
|
977
|
+
via the standard parent-gone cascade.
|
|
978
|
+
"""
|
|
979
|
+
cleaned: list[PartialBracketLeg] = []
|
|
980
|
+
for leg in self.iter_legs_for_parent(symbol, from_entry):
|
|
981
|
+
if leg.leg_state != LEG_STATE_PENDING_ENTRY:
|
|
982
|
+
continue
|
|
983
|
+
self._transition(
|
|
984
|
+
leg, LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
|
|
985
|
+
close_row=True,
|
|
986
|
+
extras_patch={'abort_reason': reason},
|
|
987
|
+
)
|
|
988
|
+
cleaned.append(leg)
|
|
989
|
+
return cleaned
|
|
990
|
+
|
|
991
|
+
# === Cancel-tentative state machine ===================================
|
|
992
|
+
|
|
993
|
+
def mark_legs_cancel_tentative(
|
|
994
|
+
self,
|
|
995
|
+
parent_from_entry: str,
|
|
996
|
+
*,
|
|
997
|
+
reason: str,
|
|
998
|
+
now_ms: int,
|
|
999
|
+
) -> list[PartialBracketLeg]:
|
|
1000
|
+
"""Flip every ``pending_entry`` leg of one parent to
|
|
1001
|
+
``cancel_tentative``.
|
|
1002
|
+
|
|
1003
|
+
Called by the sync engine's swallowed-unknown branch of
|
|
1004
|
+
``_dispatch_cancel`` when ``execute_cancel`` raises
|
|
1005
|
+
:class:`OrderDispositionUnknownError` on a parent entry. The
|
|
1006
|
+
legs stay live (the row's ``closed_ts_ms`` is not set) and are
|
|
1007
|
+
excluded from worst-SL contribution / price-tick arming until
|
|
1008
|
+
either the ``reconcile()`` cancel-retry-loop resolves the
|
|
1009
|
+
disposition (→ :meth:`confirm_cancel_tentative` or
|
|
1010
|
+
:meth:`restore_legs_from_cancel_tentative`) or the stale-grace
|
|
1011
|
+
deadline expires (→ ``DEGRADED_HALT`` by the caller).
|
|
1012
|
+
|
|
1013
|
+
Already ``armed`` legs are NOT affected: per the dossier's leg-
|
|
1014
|
+
trio atomicity invariant (:meth:`on_parent_entry_filled` flips
|
|
1015
|
+
the whole trio atomically), ``ARMED + PENDING_ENTRY`` coexistence
|
|
1016
|
+
under a single parent is structurally impossible while the parent
|
|
1017
|
+
is still pending.
|
|
1018
|
+
|
|
1019
|
+
:param parent_from_entry: The parent ``EntryIntent.intent_key``
|
|
1020
|
+
(≡ Pine ``from_entry`` on every child :class:`ExitIntent`).
|
|
1021
|
+
:param reason: Audit reason recorded on the leg row's extras.
|
|
1022
|
+
:param now_ms: Wall-clock timestamp (ms) marking entry into
|
|
1023
|
+
the cancel-tentative state. Persisted under
|
|
1024
|
+
:data:`EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS`; survives
|
|
1025
|
+
restart so the stale-grace deadline can be rehydrated.
|
|
1026
|
+
:return: List of legs that were transitioned.
|
|
1027
|
+
"""
|
|
1028
|
+
flipped: list[PartialBracketLeg] = []
|
|
1029
|
+
targets = [
|
|
1030
|
+
leg for leg in self._legs.values()
|
|
1031
|
+
if leg.from_entry == parent_from_entry
|
|
1032
|
+
and leg.leg_state == LEG_STATE_PENDING_ENTRY
|
|
1033
|
+
]
|
|
1034
|
+
for leg in targets:
|
|
1035
|
+
self._transition(
|
|
1036
|
+
leg, LEG_STATE_CANCEL_TENTATIVE,
|
|
1037
|
+
close_row=False,
|
|
1038
|
+
extras_patch={
|
|
1039
|
+
EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: now_ms,
|
|
1040
|
+
'cancel_tentative_reason': reason,
|
|
1041
|
+
},
|
|
1042
|
+
)
|
|
1043
|
+
flipped.append(leg)
|
|
1044
|
+
return flipped
|
|
1045
|
+
|
|
1046
|
+
def restore_legs_from_cancel_tentative(
|
|
1047
|
+
self,
|
|
1048
|
+
parent_from_entry: str,
|
|
1049
|
+
*,
|
|
1050
|
+
parent_filled: bool,
|
|
1051
|
+
reason: str,
|
|
1052
|
+
) -> list[PartialBracketLeg]:
|
|
1053
|
+
"""Reverse :meth:`mark_legs_cancel_tentative` for one parent.
|
|
1054
|
+
|
|
1055
|
+
Called when the cancel-retry-loop or a late parent FILL event
|
|
1056
|
+
proves the parent is in fact alive (the cancel attempt that
|
|
1057
|
+
timed out never actually landed). Legs return to
|
|
1058
|
+
:data:`LEG_STATE_PENDING_ENTRY` when the parent is still pending
|
|
1059
|
+
(no fill event observed yet) or directly to
|
|
1060
|
+
:data:`LEG_STATE_ARMED` when the parent has filled
|
|
1061
|
+
(``parent_filled=True``). In the armed case the caller is
|
|
1062
|
+
responsible for re-registering the parent with the
|
|
1063
|
+
``NativeFailsafeManager``.
|
|
1064
|
+
|
|
1065
|
+
Idempotent: when no leg is in cancel-tentative for the parent
|
|
1066
|
+
(e.g. because both the event-driven path and the reconcile-retry
|
|
1067
|
+
path delivered the resolution on the same tick), the second
|
|
1068
|
+
call is a no-op.
|
|
1069
|
+
|
|
1070
|
+
:param parent_from_entry: The parent ``EntryIntent.intent_key``
|
|
1071
|
+
whose tentative legs are being restored.
|
|
1072
|
+
:param parent_filled: When ``True``, restore to ``armed``
|
|
1073
|
+
(the parent is filled, the leg should resume worst-SL
|
|
1074
|
+
contribution and tick processing). When ``False``,
|
|
1075
|
+
restore to ``pending_entry`` (parent still pending; the
|
|
1076
|
+
next :meth:`on_parent_entry_filled` will promote).
|
|
1077
|
+
:param reason: Audit reason recorded on the leg row's extras.
|
|
1078
|
+
:return: List of legs that were transitioned. Empty if no
|
|
1079
|
+
tentative leg matched the parent (idempotent no-op).
|
|
1080
|
+
"""
|
|
1081
|
+
target_state = (
|
|
1082
|
+
LEG_STATE_ARMED if parent_filled else LEG_STATE_PENDING_ENTRY
|
|
1083
|
+
)
|
|
1084
|
+
restored: list[PartialBracketLeg] = []
|
|
1085
|
+
targets = [
|
|
1086
|
+
leg for leg in self._legs.values()
|
|
1087
|
+
if leg.from_entry == parent_from_entry
|
|
1088
|
+
and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
|
|
1089
|
+
]
|
|
1090
|
+
for leg in targets:
|
|
1091
|
+
self._transition(
|
|
1092
|
+
leg, target_state,
|
|
1093
|
+
close_row=False,
|
|
1094
|
+
extras_patch={
|
|
1095
|
+
EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: None,
|
|
1096
|
+
'cancel_tentative_resolved_reason': reason,
|
|
1097
|
+
},
|
|
1098
|
+
)
|
|
1099
|
+
restored.append(leg)
|
|
1100
|
+
return restored
|
|
1101
|
+
|
|
1102
|
+
def confirm_cancel_tentative(
|
|
1103
|
+
self,
|
|
1104
|
+
parent_from_entry: str,
|
|
1105
|
+
*,
|
|
1106
|
+
reason: str,
|
|
1107
|
+
) -> list[PartialBracketLeg]:
|
|
1108
|
+
"""Resolve :meth:`mark_legs_cancel_tentative` forward for one parent.
|
|
1109
|
+
|
|
1110
|
+
Called when the cancel-retry-loop receives a
|
|
1111
|
+
:attr:`CancelDispositionOutcome.CANCEL_CONFIRMED`,
|
|
1112
|
+
:attr:`CancelDispositionOutcome.STILL_OPEN`, or
|
|
1113
|
+
:attr:`CancelDispositionOutcome.TOO_LATE_TO_CANCEL` outcome,
|
|
1114
|
+
or when a parent CANCELLED order event arrives for a parent
|
|
1115
|
+
currently in cancel-tentative. The tentative legs are flipped
|
|
1116
|
+
to :data:`LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED` (terminal)
|
|
1117
|
+
and their rows are closed.
|
|
1118
|
+
|
|
1119
|
+
Idempotent: if no tentative leg matches the parent (because
|
|
1120
|
+
a previous call already terminated them), this is a no-op.
|
|
1121
|
+
|
|
1122
|
+
:param parent_from_entry: The parent ``EntryIntent.intent_key``
|
|
1123
|
+
whose tentative legs are being confirmed-cancelled.
|
|
1124
|
+
:param reason: Audit reason recorded on the leg row's extras.
|
|
1125
|
+
:return: List of legs that were transitioned. Empty if no
|
|
1126
|
+
tentative leg matched (idempotent no-op).
|
|
1127
|
+
"""
|
|
1128
|
+
confirmed: list[PartialBracketLeg] = []
|
|
1129
|
+
targets = [
|
|
1130
|
+
leg for leg in self._legs.values()
|
|
1131
|
+
if leg.from_entry == parent_from_entry
|
|
1132
|
+
and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
|
|
1133
|
+
]
|
|
1134
|
+
for leg in targets:
|
|
1135
|
+
self._transition(
|
|
1136
|
+
leg, LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
|
|
1137
|
+
close_row=True,
|
|
1138
|
+
extras_patch={
|
|
1139
|
+
'abort_reason': reason,
|
|
1140
|
+
EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS: None,
|
|
1141
|
+
},
|
|
1142
|
+
)
|
|
1143
|
+
confirmed.append(leg)
|
|
1144
|
+
return confirmed
|
|
1145
|
+
|
|
1146
|
+
def iter_cancel_tentative_parents(self) -> set[str]:
|
|
1147
|
+
"""Snapshot of every distinct parent ``from_entry`` that currently
|
|
1148
|
+
has at least one leg in :data:`LEG_STATE_CANCEL_TENTATIVE`.
|
|
1149
|
+
|
|
1150
|
+
Used by the sync engine's ``reconcile()`` cancel-retry-loop to
|
|
1151
|
+
decide which parents still need disposition resolution. The
|
|
1152
|
+
result is a set (not a list) so the caller can intersect /
|
|
1153
|
+
difference against its shadow map without worrying about leg-
|
|
1154
|
+
trio multiplicity.
|
|
1155
|
+
"""
|
|
1156
|
+
return {
|
|
1157
|
+
leg.from_entry for leg in self._legs.values()
|
|
1158
|
+
if leg.leg_state == LEG_STATE_CANCEL_TENTATIVE
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
def has_cancel_tentative_legs(self, parent_from_entry: str) -> bool:
|
|
1162
|
+
"""Whether any leg under ``parent_from_entry`` is in
|
|
1163
|
+
:data:`LEG_STATE_CANCEL_TENTATIVE`.
|
|
1164
|
+
|
|
1165
|
+
Quick check used by the sync engine's diff-loop refuse-and-defer
|
|
1166
|
+
guard before the adoption branch — symmetric to
|
|
1167
|
+
:meth:`has_active_legs_for_intent` but for the verification state
|
|
1168
|
+
and parent-scoped (not intent-scoped).
|
|
1169
|
+
"""
|
|
1170
|
+
for leg in self._legs.values():
|
|
1171
|
+
if leg.from_entry == parent_from_entry \
|
|
1172
|
+
and leg.leg_state == LEG_STATE_CANCEL_TENTATIVE:
|
|
1173
|
+
return True
|
|
1174
|
+
return False
|
|
1175
|
+
|
|
1176
|
+
# === Restart replay ===================================================
|
|
1177
|
+
|
|
1178
|
+
def restart_replay(self) -> None:
|
|
1179
|
+
"""Rebuild the in-memory ledger from persisted leg rows.
|
|
1180
|
+
|
|
1181
|
+
Called once by the sync engine during startup, after the
|
|
1182
|
+
journal's regular replay but before the first :meth:`sync`.
|
|
1183
|
+
Terminal rows are filtered out by
|
|
1184
|
+
:func:`iter_active_engine_trigger_partial_legs`; active rows
|
|
1185
|
+
in ``triggering`` / ``triggered_failed`` / ``triggered_unknown``
|
|
1186
|
+
are reloaded as-is and the next :meth:`on_price_tick` re-
|
|
1187
|
+
evaluates them (the sync engine's reconciliation path
|
|
1188
|
+
decides whether the prior trigger landed or needs retry).
|
|
1189
|
+
|
|
1190
|
+
Stale-price recovery (§3.5): when this method observes an
|
|
1191
|
+
``armed`` leg whose ``trigger_level`` has already been crossed
|
|
1192
|
+
by the current spot quote, it does NOT fire on the spot —
|
|
1193
|
+
firing belongs to :meth:`on_price_tick` once the sync engine
|
|
1194
|
+
supplies a fresh parent snapshot. The stale-trigger policy
|
|
1195
|
+
choice (conservative / strict, see §12 #3) lives in the sync
|
|
1196
|
+
engine, not here.
|
|
1197
|
+
|
|
1198
|
+
In-flight state recovery: legs persisted in
|
|
1199
|
+
:data:`LEG_STATE_TRIGGERING`,
|
|
1200
|
+
:data:`LEG_STATE_TRIGGERED_FAILED` or
|
|
1201
|
+
:data:`LEG_STATE_TRIGGERED_UNKNOWN` are intermediate — the
|
|
1202
|
+
process crashed between trigger detection and the close
|
|
1203
|
+
dispatch settling. :meth:`on_price_tick` only advances
|
|
1204
|
+
``armed`` legs, and no other code path acts on those
|
|
1205
|
+
intermediate states, so they would remain stuck across the
|
|
1206
|
+
restart. Demote them back to ``armed`` here so the next tick
|
|
1207
|
+
re-evaluates the trigger against a fresh parent snapshot;
|
|
1208
|
+
if the prior close already landed at the broker, the parent
|
|
1209
|
+
size will have shrunk and the safety check will cap or abort
|
|
1210
|
+
the re-fire accordingly.
|
|
1211
|
+
"""
|
|
1212
|
+
if self._store_ctx is None:
|
|
1213
|
+
return
|
|
1214
|
+
self._legs.clear()
|
|
1215
|
+
self._legs_by_oca_group.clear()
|
|
1216
|
+
self._legs_by_parent.clear()
|
|
1217
|
+
for row in iter_active_engine_trigger_partial_legs(self._store_ctx):
|
|
1218
|
+
leg = _leg_from_row(row)
|
|
1219
|
+
if leg is None:
|
|
1220
|
+
continue
|
|
1221
|
+
self._legs[leg.key] = leg
|
|
1222
|
+
if leg.oca_group is not None:
|
|
1223
|
+
self._legs_by_oca_group.setdefault(
|
|
1224
|
+
leg.oca_group, set(),
|
|
1225
|
+
).add(leg.key)
|
|
1226
|
+
self._legs_by_parent.setdefault(
|
|
1227
|
+
(leg.symbol, leg.from_entry), set(),
|
|
1228
|
+
).add(leg.key)
|
|
1229
|
+
if leg.leg_state in (
|
|
1230
|
+
LEG_STATE_TRIGGERING,
|
|
1231
|
+
LEG_STATE_TRIGGERED_FAILED,
|
|
1232
|
+
LEG_STATE_TRIGGERED_UNKNOWN,
|
|
1233
|
+
):
|
|
1234
|
+
self._transition(
|
|
1235
|
+
leg, LEG_STATE_ARMED,
|
|
1236
|
+
close_row=False,
|
|
1237
|
+
extras_patch={'rearmed_after_restart_from': leg.leg_state},
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
# === State machine plumbing ==========================================
|
|
1241
|
+
|
|
1242
|
+
def _transition(
|
|
1243
|
+
self,
|
|
1244
|
+
leg: PartialBracketLeg,
|
|
1245
|
+
new_state: str,
|
|
1246
|
+
*,
|
|
1247
|
+
close_row: bool,
|
|
1248
|
+
extras_patch: dict | None = None,
|
|
1249
|
+
qty: float | None = None,
|
|
1250
|
+
) -> None:
|
|
1251
|
+
old_state = leg.leg_state
|
|
1252
|
+
leg.leg_state = new_state
|
|
1253
|
+
if qty is not None:
|
|
1254
|
+
leg.qty = qty
|
|
1255
|
+
if extras_patch:
|
|
1256
|
+
leg.extras.update(extras_patch)
|
|
1257
|
+
if self._store_ctx is not None:
|
|
1258
|
+
update_engine_trigger_partial_leg_state(
|
|
1259
|
+
self._store_ctx,
|
|
1260
|
+
coid=leg.coid,
|
|
1261
|
+
new_leg_state=new_state,
|
|
1262
|
+
qty=qty,
|
|
1263
|
+
extras_patch=extras_patch,
|
|
1264
|
+
close_row=close_row,
|
|
1265
|
+
)
|
|
1266
|
+
if self._state_change_listener is not None:
|
|
1267
|
+
self._state_change_listener(leg, old_state, new_state)
|
|
1268
|
+
if close_row:
|
|
1269
|
+
self._evict(leg.key)
|
|
1270
|
+
|
|
1271
|
+
def _evict(self, key: LegKey) -> None:
|
|
1272
|
+
leg = self._legs.pop(key, None)
|
|
1273
|
+
if leg is None:
|
|
1274
|
+
return
|
|
1275
|
+
if leg.oca_group is not None:
|
|
1276
|
+
group = self._legs_by_oca_group.get(leg.oca_group)
|
|
1277
|
+
if group is not None:
|
|
1278
|
+
group.discard(key)
|
|
1279
|
+
if not group:
|
|
1280
|
+
self._legs_by_oca_group.pop(leg.oca_group, None)
|
|
1281
|
+
parent_key = (leg.symbol, leg.from_entry)
|
|
1282
|
+
parent_group = self._legs_by_parent.get(parent_key)
|
|
1283
|
+
if parent_group is not None:
|
|
1284
|
+
parent_group.discard(key)
|
|
1285
|
+
if not parent_group:
|
|
1286
|
+
self._legs_by_parent.pop(parent_key, None)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
# === Helpers ==============================================================
|
|
1290
|
+
|
|
1291
|
+
class TickOffsetResolver:
|
|
1292
|
+
"""Convert a leg's ``trigger_offset`` (price units) into an absolute
|
|
1293
|
+
price level at parent-fill time.
|
|
1294
|
+
|
|
1295
|
+
Slice A keeps the resolver protocol thin — the sync engine
|
|
1296
|
+
converts raw tick fields (``profit_ticks`` / ``loss_ticks`` /
|
|
1297
|
+
``trail_points_ticks``) to price units at dispatch time before
|
|
1298
|
+
storing them in ``trigger_offset``, so the resolver itself just
|
|
1299
|
+
applies the offset to the fill price with the correct sign for
|
|
1300
|
+
the leg kind. The ``mintick`` parameter is kept on the resolver
|
|
1301
|
+
for forward-compat with a future call site that does the
|
|
1302
|
+
conversion here.
|
|
1303
|
+
"""
|
|
1304
|
+
|
|
1305
|
+
def __init__(self, mintick: float) -> None:
|
|
1306
|
+
self._mintick = mintick
|
|
1307
|
+
|
|
1308
|
+
# noinspection PyMethodMayBeStatic
|
|
1309
|
+
def resolve(
|
|
1310
|
+
self,
|
|
1311
|
+
leg: PartialBracketLeg,
|
|
1312
|
+
*,
|
|
1313
|
+
fill_price: float,
|
|
1314
|
+
parent_sign: int,
|
|
1315
|
+
) -> float | None:
|
|
1316
|
+
if leg.trigger_offset is None:
|
|
1317
|
+
return None
|
|
1318
|
+
if leg.leg_kind == LEG_KIND_TP_PARTIAL:
|
|
1319
|
+
return fill_price + parent_sign * leg.trigger_offset
|
|
1320
|
+
if leg.leg_kind == LEG_KIND_SL_PARTIAL:
|
|
1321
|
+
return fill_price - parent_sign * leg.trigger_offset
|
|
1322
|
+
if leg.leg_kind == LEG_KIND_TRAIL_PARTIAL:
|
|
1323
|
+
return fill_price - parent_sign * leg.trigger_offset
|
|
1324
|
+
return None
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _leg_from_row(row: 'OrderRow') -> PartialBracketLeg | None:
|
|
1328
|
+
extras = row.extras or {}
|
|
1329
|
+
leg_kind = extras.get(EXTRAS_KEY_LEG_KIND, '')
|
|
1330
|
+
leg_state = extras.get(EXTRAS_KEY_LEG_STATE, '')
|
|
1331
|
+
if leg_kind not in (
|
|
1332
|
+
LEG_KIND_TP_PARTIAL, LEG_KIND_SL_PARTIAL, LEG_KIND_TRAIL_PARTIAL,
|
|
1333
|
+
):
|
|
1334
|
+
return None
|
|
1335
|
+
# Accept every live state, including ``cancel_tentative``. The
|
|
1336
|
+
# tentative row is still open (``closed_ts_ms IS NULL``) and the
|
|
1337
|
+
# sync engine's cancel-retry loop drives its next transition — the
|
|
1338
|
+
# in-memory ledger must therefore carry it so
|
|
1339
|
+
# :meth:`SoftwarePartialBracketEngine.iter_cancel_tentative_parents`
|
|
1340
|
+
# and the post-restart
|
|
1341
|
+
# :meth:`OrderSyncEngine._rehydrate_cancel_tentative_from_replayed_legs`
|
|
1342
|
+
# rehydrate can see it. The ``restart_replay`` only re-arms the three
|
|
1343
|
+
# in-flight engine-owned states (``triggering`` / ``triggered_failed``
|
|
1344
|
+
# / ``triggered_unknown``), so a tentative row loaded here stays
|
|
1345
|
+
# tentative until the cancel-retry loop resolves it.
|
|
1346
|
+
if leg_state not in LEG_STATE_LIVE:
|
|
1347
|
+
return None
|
|
1348
|
+
parent_pine_entry_id = extras.get(EXTRAS_KEY_PARENT_PINE_ENTRY_ID, '')
|
|
1349
|
+
parent_entry_dispatch_ref = extras.get(
|
|
1350
|
+
EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF, '',
|
|
1351
|
+
)
|
|
1352
|
+
intent_partial_qty = float(extras.get(EXTRAS_KEY_INTENT_PARTIAL_QTY, 0.0))
|
|
1353
|
+
trigger_level = extras.get(EXTRAS_KEY_TRIGGER_LEVEL)
|
|
1354
|
+
trigger_offset = extras.get(EXTRAS_KEY_TRIGGER_OFFSET)
|
|
1355
|
+
trail_activation_level = extras.get(EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL)
|
|
1356
|
+
trail_activation_offset = extras.get(EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET)
|
|
1357
|
+
pine_id = row.pine_entry_id or ''
|
|
1358
|
+
from_entry = row.from_entry or ''
|
|
1359
|
+
return PartialBracketLeg(
|
|
1360
|
+
coid=row.client_order_id,
|
|
1361
|
+
symbol=row.symbol,
|
|
1362
|
+
pine_id=pine_id,
|
|
1363
|
+
from_entry=from_entry,
|
|
1364
|
+
leg_kind=leg_kind,
|
|
1365
|
+
leg_state=leg_state,
|
|
1366
|
+
side=row.side,
|
|
1367
|
+
qty=row.qty,
|
|
1368
|
+
intent_key=row.intent_key or '',
|
|
1369
|
+
parent_pine_entry_id=parent_pine_entry_id,
|
|
1370
|
+
parent_entry_dispatch_ref=parent_entry_dispatch_ref,
|
|
1371
|
+
intent_partial_qty=intent_partial_qty,
|
|
1372
|
+
trigger_level=trigger_level,
|
|
1373
|
+
trigger_offset=trigger_offset,
|
|
1374
|
+
trail_activation_level=trail_activation_level,
|
|
1375
|
+
trail_activation_offset=trail_activation_offset,
|
|
1376
|
+
oca_group=extras.get(EXTRAS_KEY_OCA_GROUP),
|
|
1377
|
+
oca_type=extras.get(EXTRAS_KEY_OCA_TYPE),
|
|
1378
|
+
extras=dict(extras),
|
|
1379
|
+
)
|