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,1600 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the broker plugin system.
|
|
3
|
+
|
|
4
|
+
These classes form the protocol between Pine Script, the Order Sync Engine,
|
|
5
|
+
and a concrete :class:`~pynecore.core.plugin.broker.BrokerPlugin`. Intent
|
|
6
|
+
objects describe what the script wants; Event objects describe what the
|
|
7
|
+
exchange actually did; Exchange* objects are snapshots of exchange state.
|
|
8
|
+
|
|
9
|
+
See ``docs/pynecore/plugin-system/broker-plugin-plan.md`` for the full design.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from pynecore.core.broker.idempotency import (
|
|
18
|
+
CLIENT_ORDER_ID_MAX_LEN,
|
|
19
|
+
build_client_order_id,
|
|
20
|
+
encode_wire_client_order_id,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pynecore.core.broker.exceptions import BracketAttachAfterFillRejectedError
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'OrderStatus',
|
|
28
|
+
'OrderType',
|
|
29
|
+
'LegType',
|
|
30
|
+
'OcaType',
|
|
31
|
+
'OcaPartialFillPolicy',
|
|
32
|
+
'CapabilityLevel',
|
|
33
|
+
'ExchangeOrder',
|
|
34
|
+
'OrderEvent',
|
|
35
|
+
'ExchangePosition',
|
|
36
|
+
'PositionLeg',
|
|
37
|
+
'ExchangeCapabilities',
|
|
38
|
+
'EntryIntent',
|
|
39
|
+
'ExitIntent',
|
|
40
|
+
'CloseIntent',
|
|
41
|
+
'CancelIntent',
|
|
42
|
+
'BracketAttachRejectContext',
|
|
43
|
+
'PendingDefensiveClose',
|
|
44
|
+
'DispatchEnvelope',
|
|
45
|
+
'ScriptRequirements',
|
|
46
|
+
'InterceptorResult',
|
|
47
|
+
'BrokerEvent',
|
|
48
|
+
'AuthenticationFailedEvent',
|
|
49
|
+
'BracketRegisteredEvent',
|
|
50
|
+
'LegPartialRepairedEvent',
|
|
51
|
+
'LegRepairFailedEvent',
|
|
52
|
+
'BracketReconstructedEvent',
|
|
53
|
+
'ManualInterventionRequiredEvent',
|
|
54
|
+
'QuarantineEnteredEvent',
|
|
55
|
+
'ProtectionDegradedEvent',
|
|
56
|
+
'NativeFailsafeStateTransitionEvent',
|
|
57
|
+
'PartialBracketBlockedDegradedFailsafeEvent',
|
|
58
|
+
'EntryBlockedDegradedFailsafeEvent',
|
|
59
|
+
'EntrySkippedDueToDegradedFailsafeEvent',
|
|
60
|
+
'BrokerNativeFailsafeUnavailableEvent',
|
|
61
|
+
'BrokerNativeFailsafeExternalEditEvent',
|
|
62
|
+
'BrokerNativeFailsafeFullCloseEvent',
|
|
63
|
+
'CancelDispositionOutcome',
|
|
64
|
+
'PartialBracketCancelTentativeStartedEvent',
|
|
65
|
+
'PartialBracketCancelTentativeResolvedEvent',
|
|
66
|
+
'PartialBracketCancelTentativeDegradedEvent',
|
|
67
|
+
'EntryDeferredCancelDispositionPendingEvent',
|
|
68
|
+
'INTENT_KEY_SEP',
|
|
69
|
+
'format_intent_key',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Field separator inside a compound intent_key (``pine_id<SEP>from_entry`` for
|
|
74
|
+
# ExitIntent / CancelIntent). NUL can never appear in a Pine identifier, so the
|
|
75
|
+
# split back to (pine_id, from_entry) is unambiguous — but it renders as an ugly
|
|
76
|
+
# ``\x00`` in logs, so user-facing messages route the key through
|
|
77
|
+
# :func:`format_intent_key`.
|
|
78
|
+
INTENT_KEY_SEP = "\0"
|
|
79
|
+
# Readable separator substituted for :data:`INTENT_KEY_SEP` in log output.
|
|
80
|
+
_INTENT_KEY_DISPLAY_SEP = "|"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def format_intent_key(key: str | None) -> str | None:
|
|
84
|
+
"""Render an ``intent_key`` for a human-readable log line.
|
|
85
|
+
|
|
86
|
+
A compound exit / cancel key is ``pine_id<NUL>from_entry``; the raw NUL
|
|
87
|
+
prints as ``\\x00`` and clutters operator logs. Swap it for a readable
|
|
88
|
+
separator. Keys without the separator (entry / close intents, plain pine
|
|
89
|
+
ids) pass through unchanged, and ``None`` passes through too, so this is
|
|
90
|
+
safe to apply to any logged key — including the optional keys carried by
|
|
91
|
+
error/context objects.
|
|
92
|
+
|
|
93
|
+
:param key: The ``intent_key`` (or any id) about to be logged, or ``None``.
|
|
94
|
+
:return: The key with :data:`INTENT_KEY_SEP` replaced by a readable glyph,
|
|
95
|
+
or ``None`` unchanged.
|
|
96
|
+
"""
|
|
97
|
+
if key is None:
|
|
98
|
+
return None
|
|
99
|
+
return key.replace(INTENT_KEY_SEP, _INTENT_KEY_DISPLAY_SEP)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class OrderStatus(StrEnum):
|
|
103
|
+
PENDING = "pending"
|
|
104
|
+
OPEN = "open"
|
|
105
|
+
PARTIALLY_FILLED = "partial"
|
|
106
|
+
FILLED = "filled"
|
|
107
|
+
CANCELLED = "cancelled"
|
|
108
|
+
REJECTED = "rejected"
|
|
109
|
+
EXPIRED = "expired"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class OrderType(StrEnum):
|
|
113
|
+
MARKET = "market"
|
|
114
|
+
LIMIT = "limit"
|
|
115
|
+
STOP = "stop"
|
|
116
|
+
TRAILING_STOP = "trailing_stop"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class LegType(StrEnum):
|
|
120
|
+
ENTRY = "entry"
|
|
121
|
+
TAKE_PROFIT = "tp"
|
|
122
|
+
STOP_LOSS = "sl"
|
|
123
|
+
TRAILING_STOP = "trail"
|
|
124
|
+
CLOSE = "close"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class OcaType(StrEnum):
|
|
128
|
+
"""Canonical OCA semantics for the sync engine.
|
|
129
|
+
|
|
130
|
+
The Pine-level literal values (``strategy.oca.cancel`` / ``.reduce`` /
|
|
131
|
+
``.none``) are plain strings for script compatibility; this enum is the
|
|
132
|
+
single authority the sync engine and intent builder match against. Adding
|
|
133
|
+
a new OCA semantic therefore requires exactly one source edit, not a
|
|
134
|
+
scattered grep across the intent-builder / sync-engine / validator.
|
|
135
|
+
"""
|
|
136
|
+
CANCEL = "cancel"
|
|
137
|
+
REDUCE = "reduce"
|
|
138
|
+
NONE = "none"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class OcaPartialFillPolicy(StrEnum):
|
|
142
|
+
"""How the sync engine treats a *partial* fill for OCA-cancel cascading.
|
|
143
|
+
|
|
144
|
+
On a full fill the behaviour is unambiguous: sibling orders in the same
|
|
145
|
+
OCA-cancel group must be cancelled. Partial fills are the grey zone — some
|
|
146
|
+
exchanges re-fill the remainder at a better price (risking sibling fills
|
|
147
|
+
too), others do not. The policy lets the user pick:
|
|
148
|
+
|
|
149
|
+
- :data:`FILL_CANCELS` (default): a partial fill already commits the script
|
|
150
|
+
to this side, so sibling cancel triggers immediately. Matches the
|
|
151
|
+
Pine backtester, where the first touch on any leg wins.
|
|
152
|
+
- :data:`FULL_FILL_ONLY`: wait until the leg is fully filled. Useful when
|
|
153
|
+
the user prefers siblings to stay live in case the first leg partial is
|
|
154
|
+
followed by a same-bar reversal that would otherwise lock in a
|
|
155
|
+
sub-optimal entry.
|
|
156
|
+
"""
|
|
157
|
+
FILL_CANCELS = "fill_cancels"
|
|
158
|
+
FULL_FILL_ONLY = "full_fill_only"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CapabilityLevel(StrEnum):
|
|
162
|
+
"""Tri-tone declaration of how a plugin upholds a single capability.
|
|
163
|
+
|
|
164
|
+
The plugin advertises *what it can deliver end-to-end* (not raw exchange
|
|
165
|
+
support). The four levels are an at-a-glance summary the runner, sync
|
|
166
|
+
engine, validator and CLI can all consume:
|
|
167
|
+
|
|
168
|
+
- :data:`UNSUPPORTED` — neither the exchange nor the plugin can uphold the
|
|
169
|
+
semantics; :func:`~pynecore.core.broker.validation.validate_at_startup`
|
|
170
|
+
rejects scripts that need it. Default so a missing field never silently
|
|
171
|
+
advertises a capability.
|
|
172
|
+
- :data:`SOFTWARE` — upheld in the plugin / sync engine without any
|
|
173
|
+
exchange-side primitive (e.g. polling order state, software OCA cascade,
|
|
174
|
+
netting-based reduce-only). Validation passes; latency / failure
|
|
175
|
+
semantics are the plugin's responsibility.
|
|
176
|
+
- :data:`PARTIAL_NATIVE` — the exchange supports a *subset* of the
|
|
177
|
+
semantics natively, the plugin fills the rest in software. Used when a
|
|
178
|
+
capability has axes the exchange covers some but not all of (e.g.
|
|
179
|
+
Capital.com ``amend_order``: level / SL / TP amendable, ``size`` is not
|
|
180
|
+
and needs cancel+recreate). Validation passes; PARTIAL_NATIVE is a
|
|
181
|
+
diagnostic flag, not a stricter contract — the sync engine treats it
|
|
182
|
+
the same as SOFTWARE for fallback decisions.
|
|
183
|
+
- :data:`NATIVE` — single atomic exchange call delivers the full
|
|
184
|
+
semantics (e.g. Bybit attached TP/SL, exchange-side OCA group, native
|
|
185
|
+
reduce-only flag). The sync engine may suppress its software fallback
|
|
186
|
+
paths on this level (see :class:`OrderSyncEngine`).
|
|
187
|
+
|
|
188
|
+
The string values are stable and safe to log / persist (e.g. SQLite
|
|
189
|
+
columns, ``pyne plugin info`` output).
|
|
190
|
+
"""
|
|
191
|
+
UNSUPPORTED = "unsupported"
|
|
192
|
+
SOFTWARE = "software"
|
|
193
|
+
PARTIAL_NATIVE = "partial_native"
|
|
194
|
+
NATIVE = "native"
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_supported(self) -> bool:
|
|
198
|
+
"""``True`` for every level except :data:`UNSUPPORTED`.
|
|
199
|
+
|
|
200
|
+
The validator uses this to decide whether to reject a script — a
|
|
201
|
+
SOFTWARE-level capability is just as valid as NATIVE, only the cost /
|
|
202
|
+
latency / failure profile differs.
|
|
203
|
+
"""
|
|
204
|
+
return self is not CapabilityLevel.UNSUPPORTED
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# === Exchange state snapshots ===
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class ExchangeOrder:
|
|
211
|
+
"""An order as it exists on the exchange."""
|
|
212
|
+
id: str
|
|
213
|
+
symbol: str
|
|
214
|
+
side: str # "buy" | "sell"
|
|
215
|
+
order_type: OrderType
|
|
216
|
+
qty: float
|
|
217
|
+
filled_qty: float
|
|
218
|
+
remaining_qty: float
|
|
219
|
+
price: float | None # Limit price
|
|
220
|
+
stop_price: float | None # Trigger price
|
|
221
|
+
average_fill_price: float | None
|
|
222
|
+
status: OrderStatus
|
|
223
|
+
timestamp: float # Creation time (unix seconds)
|
|
224
|
+
fee: float
|
|
225
|
+
fee_currency: str
|
|
226
|
+
reduce_only: bool = False
|
|
227
|
+
# Exchange-side clientOrderId (our allocation, echoed back by the exchange).
|
|
228
|
+
# Required for post-restart bracket reconstruction — without it, open TP/SL
|
|
229
|
+
# legs left on the exchange cannot be mapped back to Pine identity.
|
|
230
|
+
client_order_id: str | None = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class OrderEvent:
|
|
235
|
+
"""
|
|
236
|
+
A normalized fill/status event reported by a BrokerPlugin.
|
|
237
|
+
|
|
238
|
+
The plugin is responsible for mapping exchange-level events back to
|
|
239
|
+
Pine-level identity. A single Pine exit intent may become multiple
|
|
240
|
+
exchange orders (e.g. Bybit Partial TP/SL pairs), and only the plugin
|
|
241
|
+
knows the mapping.
|
|
242
|
+
|
|
243
|
+
**fill_qty is INCREMENTAL.** It is the quantity of *this* fill event,
|
|
244
|
+
not a running total. :meth:`BrokerPosition.record_fill` *adds* it to the
|
|
245
|
+
position (it never diffs against the cumulative ``order.filled_qty``), so
|
|
246
|
+
a plugin that reported ``fill_qty`` cumulatively across partials would
|
|
247
|
+
over-apply. The cumulative-on-the-order total lives in
|
|
248
|
+
:attr:`ExchangeOrder.filled_qty`; the per-event slice lives here.
|
|
249
|
+
|
|
250
|
+
**fill_id is the canonical idempotency key.** The sync engine keeps a
|
|
251
|
+
bounded seen-set keyed on ``fill_id`` and drops a fill whose ``fill_id``
|
|
252
|
+
it has already applied, as a final defence against a broker delivering
|
|
253
|
+
the same execution twice (live-push/dispatch-response replay, poll+stream
|
|
254
|
+
race, reconnect). For this gate to work the invariant is: *the same real
|
|
255
|
+
broker execution MUST carry the same ``fill_id`` across every path that
|
|
256
|
+
can surface it.* Use the broker-native execution/deal id whenever one
|
|
257
|
+
exists. ``fill_id`` may be ``None`` only when the plugin has no
|
|
258
|
+
broker-native execution id for that path (e.g. a cumulative-only
|
|
259
|
+
reconcile/bridge emission); in that case the plugin MUST guarantee via
|
|
260
|
+
its persisted ``filled_qty`` cursor that no other path re-emits the same
|
|
261
|
+
execution. A ``None`` ``fill_id`` is a no-op for the gate (applied as-is),
|
|
262
|
+
so it must never be used for a path that a duplicate could also reach.
|
|
263
|
+
"""
|
|
264
|
+
order: ExchangeOrder
|
|
265
|
+
event_type: str # "created" | "amended" | "filled" | "partial" | "cancelled" | "rejected"
|
|
266
|
+
fill_price: float | None
|
|
267
|
+
fill_qty: float | None # INCREMENTAL qty of THIS event (see class docstring)
|
|
268
|
+
timestamp: float
|
|
269
|
+
# Pine-level identity (filled by the plugin, used by sync engine + BrokerPosition)
|
|
270
|
+
pine_id: str | None = None # Pine order ID (entry id or exit id)
|
|
271
|
+
from_entry: str | None = None # Which entry this fill belongs to (for exits)
|
|
272
|
+
leg_type: LegType | None = None # Which leg of a bracket filled
|
|
273
|
+
fee: float = 0.0
|
|
274
|
+
fee_currency: str = ""
|
|
275
|
+
# Stable broker-native execution/deal id (see class docstring). Canonical
|
|
276
|
+
# across every path that can surface the same execution; the sync engine's
|
|
277
|
+
# duplicate-fill gate keys on it. ``None`` => no-op for the gate.
|
|
278
|
+
fill_id: str | None = None
|
|
279
|
+
# Set only on ``cancelled`` events synthesised by the
|
|
280
|
+
# :class:`~pynecore.core.broker.disappearance.DisappearanceTracker` after
|
|
281
|
+
# it has already applied the ``on_unexpected_cancel`` policy for the
|
|
282
|
+
# disappearance. The sync engine's WS-push handler applies the SAME policy
|
|
283
|
+
# for venue-pushed external cancels (``False`` here), so this flag gates
|
|
284
|
+
# the handler OFF for the tracker's own events — re-applying would double
|
|
285
|
+
# the audit / sibling sweep and raise the ``halt`` from the wrong place.
|
|
286
|
+
from_disappearance_tracker: bool = False
|
|
287
|
+
|
|
288
|
+
def __str__(self) -> str:
|
|
289
|
+
parts = [
|
|
290
|
+
self.event_type.upper(),
|
|
291
|
+
f"id={self.order.id}",
|
|
292
|
+
f"side={self.order.side}",
|
|
293
|
+
f"qty={self.order.qty}",
|
|
294
|
+
f"filled={self.order.filled_qty}",
|
|
295
|
+
]
|
|
296
|
+
if self.fill_price is not None:
|
|
297
|
+
parts.append(f"price={self.fill_price}")
|
|
298
|
+
if self.pine_id:
|
|
299
|
+
parts.append(f"pine={self.pine_id!r}")
|
|
300
|
+
if self.from_entry:
|
|
301
|
+
parts.append(f"from={self.from_entry!r}")
|
|
302
|
+
if self.leg_type is not None:
|
|
303
|
+
parts.append(f"leg={self.leg_type.value}")
|
|
304
|
+
return " ".join(parts)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass
|
|
308
|
+
class ExchangePosition:
|
|
309
|
+
"""Current position on the exchange — the engine's read-side view.
|
|
310
|
+
|
|
311
|
+
Venue-agnostic. On futures/margin venues it mirrors the broker's
|
|
312
|
+
native position object. On spot venues no such object exists, so the
|
|
313
|
+
plugin synthesizes it from its own fill ledger: ``side`` long/flat,
|
|
314
|
+
``size`` = net base inventory, ``entry_price`` = ledger VWAP,
|
|
315
|
+
``unrealized_pnl`` = (mark − VWAP) × size, ``leverage=1.0``,
|
|
316
|
+
``liquidation_price=None``, ``margin_mode="cash"``.
|
|
317
|
+
|
|
318
|
+
The engine's reconcile consumes ``size``, ``side``, ``entry_price``
|
|
319
|
+
and ``unrealized_pnl``; the remaining fields are informational.
|
|
320
|
+
"""
|
|
321
|
+
symbol: str
|
|
322
|
+
side: str # "long" | "short" | "flat"
|
|
323
|
+
size: float
|
|
324
|
+
entry_price: float
|
|
325
|
+
unrealized_pnl: float
|
|
326
|
+
liquidation_price: float | None
|
|
327
|
+
leverage: float
|
|
328
|
+
margin_mode: str # "cross" | "isolated" | "cash" (spot)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass
|
|
332
|
+
class PositionLeg:
|
|
333
|
+
"""One raw open position ("leg") on a hedging-capable exchange.
|
|
334
|
+
|
|
335
|
+
A hedging account can hold several simultaneous positions for the same
|
|
336
|
+
symbol — each its own broker position id, opened by a single order. Pine
|
|
337
|
+
Script sees only one net one-way position per symbol, so the core
|
|
338
|
+
:mod:`~pynecore.core.broker.emulator` aggregates these legs for reads and
|
|
339
|
+
selects among them (oldest first) for reduce / close / reversal operations.
|
|
340
|
+
|
|
341
|
+
A plugin that opts into one-way emulation returns these via its
|
|
342
|
+
``fetch_raw_positions`` transport primitive and performs ZERO aggregation
|
|
343
|
+
itself: on a hedging account it returns one leg per open broker position;
|
|
344
|
+
on a netting account it returns at most one.
|
|
345
|
+
|
|
346
|
+
:ivar leg_id: Broker-native position identifier (e.g. cTrader
|
|
347
|
+
``positionId`` as a string), used to address the leg in ``close_leg``.
|
|
348
|
+
:ivar symbol: The Pine symbol this leg belongs to.
|
|
349
|
+
:ivar side: Direction that opened the leg — ``"buy"`` (long) or
|
|
350
|
+
``"sell"`` (short). Matches :class:`ExchangeOrder` side wording.
|
|
351
|
+
:ivar qty: Open size in Pine units (always positive; ``side`` carries the
|
|
352
|
+
direction).
|
|
353
|
+
:ivar entry_price: Volume-weighted open price of the leg.
|
|
354
|
+
:ivar open_time: Leg open time (unix seconds). The FIFO close order is
|
|
355
|
+
derived from this, so it MUST be a stable, broker-reported value
|
|
356
|
+
(not a local wall clock) for replay determinism.
|
|
357
|
+
:ivar unrealized_pnl: Broker-reported mark-to-market P&L for this leg.
|
|
358
|
+
"""
|
|
359
|
+
leg_id: str
|
|
360
|
+
symbol: str
|
|
361
|
+
side: str # "buy" (long) | "sell" (short)
|
|
362
|
+
qty: float
|
|
363
|
+
entry_price: float
|
|
364
|
+
open_time: float
|
|
365
|
+
unrealized_pnl: float = 0.0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass
|
|
369
|
+
class ExchangeCapabilities:
|
|
370
|
+
"""
|
|
371
|
+
What the plugin can deliver end-to-end for the script, not raw exchange
|
|
372
|
+
support. Declared once at startup. Each capability is a
|
|
373
|
+
:class:`CapabilityLevel` — :data:`~CapabilityLevel.UNSUPPORTED` (default)
|
|
374
|
+
rejects scripts that need it via
|
|
375
|
+
:func:`~pynecore.core.broker.validation.validate_at_startup`; any other
|
|
376
|
+
level passes the validator. The level distinction
|
|
377
|
+
(NATIVE / PARTIAL_NATIVE / SOFTWARE) is a diagnostic — the sync engine
|
|
378
|
+
treats NATIVE as "exchange owns this; suppress my software fallback"
|
|
379
|
+
for the fields it explicitly checks (``oca_cancel``, ``tp_sl_bracket``),
|
|
380
|
+
and everything else as "engine still runs the fallback path". Plugins
|
|
381
|
+
that can guarantee end-to-end atomic delivery should declare NATIVE so
|
|
382
|
+
the engine can skip its emulation.
|
|
383
|
+
"""
|
|
384
|
+
# === Order types ===
|
|
385
|
+
# NATIVE = exchange has the order type as a first-class primitive.
|
|
386
|
+
# SOFTWARE = plugin emulates it (e.g. a poll loop that converts a
|
|
387
|
+
# client-side trigger price into a market submit). UNSUPPORTED rejects
|
|
388
|
+
# scripts that use the corresponding Pine parameter.
|
|
389
|
+
#
|
|
390
|
+
# A both-set Pine entry (``strategy.entry(limit=, stop=)``) is NOT a
|
|
391
|
+
# single stop-limit primitive — it is two OCO legs. The broker layer
|
|
392
|
+
# places the LIMIT leg as a native resting order and arms the STOP leg
|
|
393
|
+
# as a software price-watch that fires a MARKET order, so no dedicated
|
|
394
|
+
# stop-limit capability is needed.
|
|
395
|
+
stop_order: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
396
|
+
# NATIVE = server-side trailing stop (e.g. Capital.com
|
|
397
|
+
# ``trailingStop=true, stopDistance``). SOFTWARE = plugin tracks the
|
|
398
|
+
# last extreme and amends the SL each tick / bar.
|
|
399
|
+
trailing_stop: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
400
|
+
|
|
401
|
+
# === Exit bracket (TP+SL with OCA reduce semantics) ===
|
|
402
|
+
# NATIVE = single atomic exchange call attaches both legs (Bybit V5
|
|
403
|
+
# attached TP/SL, Capital.com position-attribute bracket). The sync
|
|
404
|
+
# engine's partial-fill bracket-amend path is suppressed at this level.
|
|
405
|
+
# PARTIAL_NATIVE = the exchange takes one leg natively but the other
|
|
406
|
+
# requires a follow-up call (rare — e.g. SL on the position but TP only
|
|
407
|
+
# as a separate working order). Engine fallback stays active.
|
|
408
|
+
# SOFTWARE = the plugin issues two reduce-only orders and runs the
|
|
409
|
+
# OCA-reduce / cascade-cancel logic itself.
|
|
410
|
+
tp_sl_bracket: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
411
|
+
|
|
412
|
+
# Mechanism the plugin uses to deliver a partial-qty exit bracket
|
|
413
|
+
# (``strategy.exit(qty=N, from_entry="L", ...)`` with ``N`` less than
|
|
414
|
+
# the full row qty entered under ``"L"``).
|
|
415
|
+
# NATIVE = the exchange supports partial-qty bracket inside a single
|
|
416
|
+
# position as a first-class primitive (placeholder; no current broker
|
|
417
|
+
# delivers this).
|
|
418
|
+
# SOFTWARE = the plugin delivers the bracket without a single native
|
|
419
|
+
# call. The engine drives an in-memory leg state machine per
|
|
420
|
+
# ``strategy.exit`` and dispatches a partial close through the plugin's
|
|
421
|
+
# ``execute_close`` route when the trigger level is crossed. Used by
|
|
422
|
+
# per-deal CFD brokers (Capital.com, IG, OANDA).
|
|
423
|
+
# UNSUPPORTED = the validator rejects such scripts at startup rather
|
|
424
|
+
# than silently covering the whole row.
|
|
425
|
+
partial_qty_bracket_exit: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
426
|
+
|
|
427
|
+
# Companion to :attr:`partial_qty_bracket_exit`: the level at which the
|
|
428
|
+
# plugin routes a partial-qty bracket correctly when multiple parent
|
|
429
|
+
# positions share one Pine ``entry_id`` "L" — the script's
|
|
430
|
+
# ``pyramiding > 1``, or ``strategy.order()`` (which can open multiple
|
|
431
|
+
# same-id rows even at ``pyramiding = 1``).
|
|
432
|
+
# NATIVE = the exchange routes the multi-row reduction as a first-class
|
|
433
|
+
# primitive (placeholder; no current broker delivers this).
|
|
434
|
+
# SOFTWARE = the plugin delivers it without a single native call — the
|
|
435
|
+
# engine drives one delta close against the summed parent and the
|
|
436
|
+
# exchange reduces the rows itself (e.g. Capital.com: server-side FIFO
|
|
437
|
+
# reduction, no client-side per-``dealId`` routing — §9 #13).
|
|
438
|
+
# UNSUPPORTED = the validator rejects ``pyramiding > 1`` /
|
|
439
|
+
# ``strategy.order()`` scripts that need partial-qty bracket support,
|
|
440
|
+
# rather than silently routing against just the latest row's quantity.
|
|
441
|
+
partial_qty_bracket_exit_pyramiding: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
442
|
+
|
|
443
|
+
# === OCA cancel groups ===
|
|
444
|
+
# NATIVE = the exchange tracks the OCA group and cancels siblings on
|
|
445
|
+
# fill (Bybit bracket, OKX algo orders). The sync engine SUPPRESSES its
|
|
446
|
+
# cascade-cancel logic — exchange is authoritative.
|
|
447
|
+
# SOFTWARE = the engine emits CancelIntent dispatches itself when a
|
|
448
|
+
# leg fills (the default for the vast majority of exchanges, including
|
|
449
|
+
# Capital.com — its position-attribute bracket is a separate capability
|
|
450
|
+
# under ``tp_sl_bracket``, not OCA).
|
|
451
|
+
# UNSUPPORTED = the plugin cannot deliver OCA-cancel semantics at all
|
|
452
|
+
# — scripts using ``oca_type='cancel'`` are rejected at startup.
|
|
453
|
+
oca_cancel: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
454
|
+
|
|
455
|
+
# === Order management ===
|
|
456
|
+
# NATIVE = the exchange amends every parameter (price, size, SL/TP).
|
|
457
|
+
# PARTIAL_NATIVE = some axes are amendable, others (commonly ``size``)
|
|
458
|
+
# require cancel+recreate (Capital.com is exactly this case — level /
|
|
459
|
+
# SL / TP fields amend in-place, ``size`` does not).
|
|
460
|
+
# SOFTWARE = no in-place amend; plugin always cancel+recreate.
|
|
461
|
+
amend_order: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
462
|
+
|
|
463
|
+
# NATIVE = single batch call (e.g. Binance ``DELETE /openOrders``).
|
|
464
|
+
# SOFTWARE = plugin iterates per-id cancel under the hood.
|
|
465
|
+
cancel_all: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
466
|
+
|
|
467
|
+
# NATIVE = exchange honours an explicit reduce-only flag on the order.
|
|
468
|
+
# SOFTWARE = upheld via netting / one-way mode (plugin maps an
|
|
469
|
+
# opposite-side order onto the existing position so it cannot flip).
|
|
470
|
+
# UNSUPPORTED = scripts using ``strategy.exit`` / ``strategy.close`` are
|
|
471
|
+
# rejected at startup.
|
|
472
|
+
reduce_only: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
473
|
+
|
|
474
|
+
# === Streaming & position ===
|
|
475
|
+
# NATIVE = a real WebSocket order channel.
|
|
476
|
+
# SOFTWARE = plugin emulates the stream by polling REST endpoints and
|
|
477
|
+
# diffing snapshots (Capital.com ``GET /workingorders`` +
|
|
478
|
+
# ``GET /positions`` cadence).
|
|
479
|
+
watch_orders: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
480
|
+
|
|
481
|
+
# NATIVE = a single REST/WS read returns the position state.
|
|
482
|
+
# SOFTWARE = plugin reconstructs by aggregating fills locally.
|
|
483
|
+
# The distinction is mostly informational — both deliver the same
|
|
484
|
+
# contract.
|
|
485
|
+
fetch_position: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
486
|
+
|
|
487
|
+
# === Idempotency ===
|
|
488
|
+
# NATIVE = the exchange accepts a client-supplied order id, echoes it
|
|
489
|
+
# back on subsequent reads, AND rejects duplicate submissions of the
|
|
490
|
+
# same id (Binance/Bybit/OKX). The sync engine and recovery path can
|
|
491
|
+
# rely on the exchange to dedup retries after a timeout / restart.
|
|
492
|
+
# PARTIAL_NATIVE = the exchange echoes the client id but does NOT dedup
|
|
493
|
+
# duplicates; the plugin must dedup client-side before each dispatch
|
|
494
|
+
# (Interactive Brokers, Deribit).
|
|
495
|
+
# SOFTWARE = the exchange generates the id (Capital.com server-side
|
|
496
|
+
# ``dealReference``); the plugin dedups in its own SQLite store using
|
|
497
|
+
# :attr:`DispatchEnvelope.client_order_id` as the local key.
|
|
498
|
+
# Restart-safe recovery still works, just without exchange-side
|
|
499
|
+
# enforcement.
|
|
500
|
+
# UNSUPPORTED = neither echo nor dedup — restart/timeout retries are
|
|
501
|
+
# unsafe; live scripts are rejected at startup.
|
|
502
|
+
idempotency: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
503
|
+
|
|
504
|
+
# === Short selling ===
|
|
505
|
+
# NATIVE = the venue holds a signed position natively (margin / futures /
|
|
506
|
+
# CFD — selling more than the current long flips the book short).
|
|
507
|
+
# SOFTWARE = the plugin delivers short exposure through its own borrow /
|
|
508
|
+
# margin mechanism while the venue itself is spot-settled (placeholder;
|
|
509
|
+
# no current broker delivers this).
|
|
510
|
+
# UNSUPPORTED = the venue cannot hold a negative base position (spot).
|
|
511
|
+
# Scripts whose detected requirements include ``may_go_short`` are
|
|
512
|
+
# rejected at startup, and the sync engine arms its projected-position
|
|
513
|
+
# runtime gate: any dispatch that would take the aggregate signed
|
|
514
|
+
# position below zero halts the engine (graceful stop, never a silent
|
|
515
|
+
# skip). Mutually exclusive with a declared spot inventory port — the
|
|
516
|
+
# spot ledger models long-only exposure.
|
|
517
|
+
short_selling: CapabilityLevel = CapabilityLevel.UNSUPPORTED
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# === Pine Script intents ===
|
|
521
|
+
|
|
522
|
+
@dataclass(frozen=True)
|
|
523
|
+
class EntryIntent:
|
|
524
|
+
"""What the script wants: open or add to a position."""
|
|
525
|
+
pine_id: str # strategy.entry(id=...) or strategy.order(id=...)
|
|
526
|
+
symbol: str
|
|
527
|
+
side: str # "buy" | "sell"
|
|
528
|
+
qty: float
|
|
529
|
+
order_type: OrderType
|
|
530
|
+
limit: float | None = None # Limit price
|
|
531
|
+
stop: float | None = None # Trigger/activation price
|
|
532
|
+
oca_name: str | None = None # OCA group name (strategy.entry/order oca_name param)
|
|
533
|
+
oca_type: str | None = None # "reduce" | "cancel" | "none" (strategy.oca.*)
|
|
534
|
+
comment: str | None = None
|
|
535
|
+
alert_message: str | None = None
|
|
536
|
+
is_strategy_order: bool = False # True if from strategy.order() (no pyramiding limit)
|
|
537
|
+
# ``True`` only on the synthetic MARKET intent the entry-stop engine
|
|
538
|
+
# dispatches when the STOP leg of a both-set Pine entry fires (see
|
|
539
|
+
# :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`).
|
|
540
|
+
# The plugin uses it to pick
|
|
541
|
+
# :data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP` — a distinct
|
|
542
|
+
# client-order-id from the native LIMIT leg's
|
|
543
|
+
# :data:`~pynecore.core.broker.idempotency.KIND_ENTRY` — so the stop-fired
|
|
544
|
+
# market and the just-cancelled limit never share an idempotency key (which
|
|
545
|
+
# would make the broker's local dedup skip the market POST). ``compare=False``
|
|
546
|
+
# keeps it out of the diff equality: it is a dispatch route selector derived
|
|
547
|
+
# by the engine, not Pine-level state the diff needs to sync.
|
|
548
|
+
stop_fired_market: bool = field(default=False, compare=False)
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def intent_key(self) -> str:
|
|
552
|
+
"""Stable diff key for the sync engine."""
|
|
553
|
+
return self.pine_id
|
|
554
|
+
|
|
555
|
+
def __str__(self) -> str:
|
|
556
|
+
parts = [
|
|
557
|
+
f"ENTRY {self.side.upper()} id={self.pine_id!r}",
|
|
558
|
+
f"qty={self.qty}",
|
|
559
|
+
f"type={self.order_type.value}",
|
|
560
|
+
]
|
|
561
|
+
if self.limit is not None:
|
|
562
|
+
parts.append(f"limit={self.limit}")
|
|
563
|
+
if self.stop is not None:
|
|
564
|
+
parts.append(f"stop={self.stop}")
|
|
565
|
+
return " ".join(parts)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@dataclass(frozen=True)
|
|
569
|
+
class ExitIntent:
|
|
570
|
+
"""What the script wants: reduce/close a position via TP/SL bracket."""
|
|
571
|
+
pine_id: str # strategy.exit(id=...)
|
|
572
|
+
from_entry: str # strategy.exit(from_entry=...)
|
|
573
|
+
symbol: str
|
|
574
|
+
side: str # Exit side ("sell" for long TP/SL, "buy" for short TP/SL)
|
|
575
|
+
qty: float
|
|
576
|
+
tp_price: float | None = None # Take-profit limit price
|
|
577
|
+
sl_price: float | None = None # Stop-loss trigger price
|
|
578
|
+
trail_price: float | None = None # Trailing stop activation price
|
|
579
|
+
trail_offset: float | None = None # Trailing stop offset (price units)
|
|
580
|
+
# Raw tick values — used when exit is against a pending (unfilled) entry,
|
|
581
|
+
# so absolute prices cannot be calculated yet. The Order Sync Engine
|
|
582
|
+
# converts these to tp_price/sl_price after the entry fills.
|
|
583
|
+
profit_ticks: float | None = None
|
|
584
|
+
loss_ticks: float | None = None
|
|
585
|
+
trail_points_ticks: float | None = None
|
|
586
|
+
oca_name: str | None = None # OCA group name (auto: __exit_{id}_{from_entry}_oca__)
|
|
587
|
+
oca_type: str | None = None # "reduce" | "cancel" | "none" (default: reduce for exits)
|
|
588
|
+
comment: str | None = None
|
|
589
|
+
comment_profit: str | None = None
|
|
590
|
+
comment_loss: str | None = None
|
|
591
|
+
comment_trailing: str | None = None
|
|
592
|
+
alert_message: str | None = None
|
|
593
|
+
# One-way Pine semantics: every strategy.exit is reduce-only by definition.
|
|
594
|
+
# A manual position close while the exit is pending must not flip the
|
|
595
|
+
# book back to the other side. The plugin must pass this to the exchange
|
|
596
|
+
# (Binance/Bybit/OKX ``reduceOnly``, Capital.com force-close, etc.).
|
|
597
|
+
# ``False`` is rejected at construction — a future ``HedgeBrokerPlugin``
|
|
598
|
+
# subclass will introduce a separate hedge-aware intent rather than flip
|
|
599
|
+
# this flag.
|
|
600
|
+
reduce_only: bool = True
|
|
601
|
+
# ``True`` when the script asked for a TP/SL/trailing bracket on a
|
|
602
|
+
# *fraction* of the parent entry's declared quantity
|
|
603
|
+
# (``strategy.exit(qty=N, from_entry='L', tp=...)`` with ``N`` strictly
|
|
604
|
+
# less than the declared total under ``'L'``). The intent builder
|
|
605
|
+
# fills this from ``position.entry_orders[from_entry].size`` — the
|
|
606
|
+
# script's declaration, not the broker's actual fill — falling back
|
|
607
|
+
# to the ``open_trades`` aggregate when the entry Order has been
|
|
608
|
+
# cancelled / cleared. The order sync engine dispatches on this flag
|
|
609
|
+
# together with ``caps.partial_qty_bracket_exit`` to pick between the
|
|
610
|
+
# native bracket path (NATIVE) and the engine-side trigger state
|
|
611
|
+
# machine (SOFTWARE). A bracket on the whole row keeps this ``False``
|
|
612
|
+
# and falls back to the existing full-row path.
|
|
613
|
+
#
|
|
614
|
+
# ``compare=False`` deliberately excludes the flag from
|
|
615
|
+
# :meth:`__eq__` / :meth:`__hash__`: it is a *route selector* derived
|
|
616
|
+
# from the intent, not state that needs to be diff-synced to the
|
|
617
|
+
# broker. The sync engine's qty-cap reconciliation
|
|
618
|
+
# (``_sync_pine_exit_qty``) mutates ``exit_orders[(exit_id, from_entry
|
|
619
|
+
# )].size`` after a partial entry fill so the next ``build_intents``
|
|
620
|
+
# produces an ExitIntent with the capped qty; including the derived
|
|
621
|
+
# flag in equality would let that cap mutation flip the flag and
|
|
622
|
+
# trigger a spurious second ``modify_exit`` dispatch even though
|
|
623
|
+
# nothing the broker needs to know has changed.
|
|
624
|
+
is_partial_qty_bracket: bool = field(default=False, compare=False)
|
|
625
|
+
|
|
626
|
+
def __post_init__(self) -> None:
|
|
627
|
+
if not self.reduce_only:
|
|
628
|
+
raise ValueError(
|
|
629
|
+
"ExitIntent.reduce_only must be True — one-way Pine semantics. "
|
|
630
|
+
"Hedge-mode intents belong on a future HedgeBrokerPlugin subclass."
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
@property
|
|
634
|
+
def intent_key(self) -> str:
|
|
635
|
+
"""
|
|
636
|
+
Stable diff key for the sync engine.
|
|
637
|
+
|
|
638
|
+
Cannot be just pine_id — strategy.exit(id="TP") can create separate
|
|
639
|
+
exit orders for different from_entry values (e.g. "Long" and "Short").
|
|
640
|
+
The (pine_id, from_entry) tuple is the unique key.
|
|
641
|
+
"""
|
|
642
|
+
return f"{self.pine_id}{INTENT_KEY_SEP}{self.from_entry}"
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def has_unresolved_ticks(self) -> bool:
|
|
646
|
+
"""True if tick-based prices need entry fill price to resolve."""
|
|
647
|
+
return (self.profit_ticks is not None or self.loss_ticks is not None
|
|
648
|
+
or self.trail_points_ticks is not None)
|
|
649
|
+
|
|
650
|
+
def __str__(self) -> str:
|
|
651
|
+
parts = [
|
|
652
|
+
f"EXIT id={self.pine_id!r}",
|
|
653
|
+
f"from={self.from_entry!r}",
|
|
654
|
+
f"qty={self.qty}",
|
|
655
|
+
]
|
|
656
|
+
if self.tp_price is not None:
|
|
657
|
+
parts.append(f"tp={self.tp_price}")
|
|
658
|
+
if self.sl_price is not None:
|
|
659
|
+
parts.append(f"sl={self.sl_price}")
|
|
660
|
+
if self.trail_price is not None or self.trail_offset is not None:
|
|
661
|
+
parts.append(f"trail={self.trail_price}/{self.trail_offset}")
|
|
662
|
+
return " ".join(parts)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@dataclass(frozen=True)
|
|
666
|
+
class CloseIntent:
|
|
667
|
+
"""
|
|
668
|
+
What the script wants: close position with market order.
|
|
669
|
+
|
|
670
|
+
The ``immediately`` flag mirrors TradingView backtest semantics: without
|
|
671
|
+
it, a close waits for the next bar's open. With ``calc_on_every_tick``
|
|
672
|
+
a non-immediate close can delay execution by an entire bar in live
|
|
673
|
+
trading, which is why the flag exists.
|
|
674
|
+
"""
|
|
675
|
+
pine_id: str # strategy.close(id=...) or strategy.close_all()
|
|
676
|
+
symbol: str
|
|
677
|
+
side: str # "sell" to close long, "buy" to close short
|
|
678
|
+
qty: float
|
|
679
|
+
immediately: bool = False
|
|
680
|
+
comment: str | None = None
|
|
681
|
+
alert_message: str | None = None
|
|
682
|
+
# Same invariant as :attr:`ExitIntent.reduce_only` — a close can never
|
|
683
|
+
# flip the book to the other side in one-way Pine mode.
|
|
684
|
+
reduce_only: bool = True
|
|
685
|
+
|
|
686
|
+
def __post_init__(self) -> None:
|
|
687
|
+
if not self.reduce_only:
|
|
688
|
+
raise ValueError(
|
|
689
|
+
"CloseIntent.reduce_only must be True — one-way Pine semantics."
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def intent_key(self) -> str:
|
|
694
|
+
return self.pine_id
|
|
695
|
+
|
|
696
|
+
def __str__(self) -> str:
|
|
697
|
+
return (
|
|
698
|
+
f"CLOSE id={self.pine_id!r} side={self.side} qty={self.qty}"
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@dataclass(frozen=True)
|
|
703
|
+
class CancelIntent:
|
|
704
|
+
"""
|
|
705
|
+
What the script wants: cancel a pending order.
|
|
706
|
+
|
|
707
|
+
``strategy.cancel(id)`` cancels ALL orders matching that id. For exits
|
|
708
|
+
this means every (pine_id, from_entry) pair with that pine_id. The
|
|
709
|
+
Order Sync Engine resolves the affected intent_keys and may send
|
|
710
|
+
multiple CancelIntents (one per from_entry), or a single one with
|
|
711
|
+
``from_entry=None`` meaning "cancel all exits with this pine_id".
|
|
712
|
+
"""
|
|
713
|
+
pine_id: str
|
|
714
|
+
symbol: str
|
|
715
|
+
from_entry: str | None = None
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def intent_key(self) -> str:
|
|
719
|
+
if self.from_entry is not None:
|
|
720
|
+
return f"{self.pine_id}{INTENT_KEY_SEP}{self.from_entry}"
|
|
721
|
+
return self.pine_id
|
|
722
|
+
|
|
723
|
+
def __str__(self) -> str:
|
|
724
|
+
tail = f" from={self.from_entry!r}" if self.from_entry else ""
|
|
725
|
+
return f"CANCEL id={self.pine_id!r}{tail}"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
# === Recovery contracts ===
|
|
729
|
+
|
|
730
|
+
@dataclass(frozen=True)
|
|
731
|
+
class BracketAttachRejectContext:
|
|
732
|
+
"""Recovery context for a bracket attach reject after a parent fill.
|
|
733
|
+
|
|
734
|
+
Built by the sync engine when a plugin raises
|
|
735
|
+
:class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`
|
|
736
|
+
and threaded into
|
|
737
|
+
:meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_residual_orders_after_bracket_attach_reject`
|
|
738
|
+
so the plugin can enumerate any broker-side orders the engine must
|
|
739
|
+
cancel as part of defensive recovery.
|
|
740
|
+
|
|
741
|
+
Separation of concerns: the exception carries transport + debug info
|
|
742
|
+
(chained cause, message), this context carries the recovery contract
|
|
743
|
+
(what the engine needs to settle the defensive close and rebuild
|
|
744
|
+
state on restart). Frozen + hashable so it can live inside a
|
|
745
|
+
:class:`PendingDefensiveClose` marker that is persisted across
|
|
746
|
+
restarts via the BrokerStore extras column.
|
|
747
|
+
|
|
748
|
+
:ivar intent_key: Diff key of the original (rejected) intent — used
|
|
749
|
+
to correlate the defensive close with its trigger in the audit log.
|
|
750
|
+
:ivar position_coid: Client-order-id of the unprotected open position
|
|
751
|
+
(the parent ENTRY row in BrokerStore). Universal recovery key
|
|
752
|
+
across plugins.
|
|
753
|
+
:ivar position_side: Side of the OPEN position (``"buy"`` for long,
|
|
754
|
+
``"sell"`` for short). The defensive close picks the opposite.
|
|
755
|
+
:ivar qty: Quantity of the unprotected position the close must
|
|
756
|
+
flatten.
|
|
757
|
+
:ivar symbol: Trading symbol of the unprotected position.
|
|
758
|
+
:ivar position_deal_id: Exchange-side identifier of the unprotected
|
|
759
|
+
position when the plugin can supply one. Broker-specific
|
|
760
|
+
(Capital.com deal id, IB permId, Bybit orderId, ...); recovery
|
|
761
|
+
logic must not rely on it for correctness — use ``position_coid``
|
|
762
|
+
for cross-broker lookups.
|
|
763
|
+
:ivar from_entry: Pine ``strategy.entry`` id when the rejected
|
|
764
|
+
bracket originated from a ``strategy.exit``. Populated by
|
|
765
|
+
:meth:`from_exception` from the exception or — as a fallback —
|
|
766
|
+
from the triggering intent.
|
|
767
|
+
:ivar exit_id: Pine ``strategy.exit`` id when applicable.
|
|
768
|
+
:ivar filled_qty: Quantity already filled on the parent at the time
|
|
769
|
+
of the reject. ``None`` falls back to ``qty`` (conservative).
|
|
770
|
+
:ivar error_code: Plugin-supplied exchange-side error code, if any.
|
|
771
|
+
:ivar error_message: Plugin-supplied exchange-side error message.
|
|
772
|
+
"""
|
|
773
|
+
intent_key: str
|
|
774
|
+
position_coid: str
|
|
775
|
+
position_side: str
|
|
776
|
+
qty: float
|
|
777
|
+
symbol: str
|
|
778
|
+
position_deal_id: str | None = None
|
|
779
|
+
from_entry: str | None = None
|
|
780
|
+
exit_id: str | None = None
|
|
781
|
+
filled_qty: float | None = None
|
|
782
|
+
error_code: str | None = None
|
|
783
|
+
error_message: str | None = None
|
|
784
|
+
|
|
785
|
+
@classmethod
|
|
786
|
+
def from_exception(
|
|
787
|
+
cls,
|
|
788
|
+
e: 'BracketAttachAfterFillRejectedError',
|
|
789
|
+
intent: 'EntryIntent | ExitIntent | CloseIntent | CancelIntent',
|
|
790
|
+
) -> 'BracketAttachRejectContext':
|
|
791
|
+
"""Build a context from a raised exception + its triggering intent.
|
|
792
|
+
|
|
793
|
+
``from_entry`` is taken from the exception when present;
|
|
794
|
+
otherwise it falls back to ``intent.from_entry`` for an
|
|
795
|
+
:class:`ExitIntent` or ``intent.pine_id`` for an
|
|
796
|
+
:class:`EntryIntent`. The optional plugin-supplied fields are
|
|
797
|
+
read via :func:`getattr` so plugins can adopt them at their own
|
|
798
|
+
pace without breaking the contract.
|
|
799
|
+
"""
|
|
800
|
+
from_entry = e.from_entry
|
|
801
|
+
if from_entry is None:
|
|
802
|
+
if isinstance(intent, ExitIntent):
|
|
803
|
+
from_entry = intent.from_entry
|
|
804
|
+
elif isinstance(intent, EntryIntent):
|
|
805
|
+
from_entry = intent.pine_id
|
|
806
|
+
return cls(
|
|
807
|
+
intent_key=intent.intent_key,
|
|
808
|
+
position_coid=e.position_coid,
|
|
809
|
+
position_side=e.position_side,
|
|
810
|
+
qty=e.qty,
|
|
811
|
+
symbol=e.symbol,
|
|
812
|
+
position_deal_id=e.position_deal_id,
|
|
813
|
+
from_entry=from_entry,
|
|
814
|
+
exit_id=getattr(e, 'exit_id', None),
|
|
815
|
+
filled_qty=getattr(e, 'filled_qty', None),
|
|
816
|
+
error_code=getattr(e, 'error_code', None),
|
|
817
|
+
error_message=getattr(e, 'error_message', None),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def to_dict(self) -> dict:
|
|
821
|
+
"""Serialize to a JSON-compatible dict for BrokerStore extras."""
|
|
822
|
+
return {
|
|
823
|
+
'intent_key': self.intent_key,
|
|
824
|
+
'position_coid': self.position_coid,
|
|
825
|
+
'position_side': self.position_side,
|
|
826
|
+
'qty': self.qty,
|
|
827
|
+
'symbol': self.symbol,
|
|
828
|
+
'position_deal_id': self.position_deal_id,
|
|
829
|
+
'from_entry': self.from_entry,
|
|
830
|
+
'exit_id': self.exit_id,
|
|
831
|
+
'filled_qty': self.filled_qty,
|
|
832
|
+
'error_code': self.error_code,
|
|
833
|
+
'error_message': self.error_message,
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
@classmethod
|
|
837
|
+
def from_dict(cls, data: dict) -> 'BracketAttachRejectContext':
|
|
838
|
+
"""Rebuild from a previously serialized ``to_dict`` payload.
|
|
839
|
+
|
|
840
|
+
Raises :class:`ValueError` if required fields are missing or
|
|
841
|
+
not of the expected primitive type — callers (startup replay)
|
|
842
|
+
catch this and log+skip the malformed extras row instead of
|
|
843
|
+
crashing the engine.
|
|
844
|
+
"""
|
|
845
|
+
if not isinstance(data, dict):
|
|
846
|
+
raise ValueError(
|
|
847
|
+
f"BracketAttachRejectContext payload must be a dict, "
|
|
848
|
+
f"got {type(data).__name__}"
|
|
849
|
+
)
|
|
850
|
+
try:
|
|
851
|
+
intent_key = data['intent_key']
|
|
852
|
+
position_coid = data['position_coid']
|
|
853
|
+
position_side = data['position_side']
|
|
854
|
+
qty = data['qty']
|
|
855
|
+
symbol = data['symbol']
|
|
856
|
+
except KeyError as exc:
|
|
857
|
+
raise ValueError(
|
|
858
|
+
f"BracketAttachRejectContext payload missing required "
|
|
859
|
+
f"field {exc.args[0]!r}: {data!r}"
|
|
860
|
+
) from exc
|
|
861
|
+
if not (isinstance(intent_key, str) and isinstance(position_coid, str)
|
|
862
|
+
and isinstance(position_side, str) and isinstance(symbol, str)):
|
|
863
|
+
raise ValueError(
|
|
864
|
+
f"BracketAttachRejectContext required string fields have "
|
|
865
|
+
f"wrong type: {data!r}"
|
|
866
|
+
)
|
|
867
|
+
if not isinstance(qty, (int, float)):
|
|
868
|
+
raise ValueError(
|
|
869
|
+
f"BracketAttachRejectContext.qty must be numeric: {data!r}"
|
|
870
|
+
)
|
|
871
|
+
filled_qty_raw = data.get('filled_qty')
|
|
872
|
+
if filled_qty_raw is not None and not isinstance(filled_qty_raw, (int, float)):
|
|
873
|
+
raise ValueError(
|
|
874
|
+
f"BracketAttachRejectContext.filled_qty must be numeric or "
|
|
875
|
+
f"None: {data!r}"
|
|
876
|
+
)
|
|
877
|
+
return cls(
|
|
878
|
+
intent_key=intent_key,
|
|
879
|
+
position_coid=position_coid,
|
|
880
|
+
position_side=position_side,
|
|
881
|
+
qty=float(qty),
|
|
882
|
+
symbol=symbol,
|
|
883
|
+
position_deal_id=data.get('position_deal_id'),
|
|
884
|
+
from_entry=data.get('from_entry'),
|
|
885
|
+
exit_id=data.get('exit_id'),
|
|
886
|
+
filled_qty=(
|
|
887
|
+
float(filled_qty_raw)
|
|
888
|
+
if filled_qty_raw is not None else None
|
|
889
|
+
),
|
|
890
|
+
error_code=data.get('error_code'),
|
|
891
|
+
error_message=data.get('error_message'),
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@dataclass(frozen=True)
|
|
896
|
+
class PendingDefensiveClose:
|
|
897
|
+
"""Engine-side marker for an in-flight defensive close.
|
|
898
|
+
|
|
899
|
+
Set by :meth:`OrderSyncEngine._handle_bracket_attach_after_fill_reject`
|
|
900
|
+
BEFORE the defensive close dispatches; cleared by the engine's FILL
|
|
901
|
+
handler when the close lands. While the marker lives, the entry
|
|
902
|
+
intent stays in ``_active_intents`` so a same-bar
|
|
903
|
+
:meth:`OrderSyncEngine.reconcile` cannot mistakenly classify the
|
|
904
|
+
flat broker snapshot as an external flatten — see the
|
|
905
|
+
``defensive-close-pending-lifecycle`` design doc.
|
|
906
|
+
|
|
907
|
+
Persisted to the parent entry's BrokerStore row under
|
|
908
|
+
``extras['defensive_close_pending']`` so a process restart between
|
|
909
|
+
dispatch and FILL can replay the marker; the engine's startup
|
|
910
|
+
replay then either drops the marker (FILL already recorded) or
|
|
911
|
+
re-arms residual cancel handling.
|
|
912
|
+
|
|
913
|
+
:ivar entry_id: Pine entry id this marker belongs to.
|
|
914
|
+
:ivar close_intent_key: Diff key of the synthetic defensive close.
|
|
915
|
+
:ivar close_order_ref: Broker-side order ref of the close, when the
|
|
916
|
+
plugin returned one. ``None`` if dispatch ran but the plugin did
|
|
917
|
+
not surface a ref (e.g. position-attached close on Capital.com).
|
|
918
|
+
:ivar close_client_order_id: Canonical ``client_order_id`` of the
|
|
919
|
+
synthetic close dispatch (derived from the close envelope). Set
|
|
920
|
+
as soon as the dispatch is parked or returns — used to match the
|
|
921
|
+
eventual FILL via :attr:`OrderEvent.order.client_order_id` when
|
|
922
|
+
``pine_id`` is absent and the parked order never appeared in
|
|
923
|
+
:meth:`~pynecore.core.plugin.broker.BrokerPlugin.get_open_orders`
|
|
924
|
+
(so :meth:`OrderSyncEngine._maybe_attach_defensive_close_ref`
|
|
925
|
+
never backfilled :attr:`close_order_ref`). Also used to drop the
|
|
926
|
+
parked pending row from
|
|
927
|
+
:attr:`OrderSyncEngine._pending_verification` (and the persisted
|
|
928
|
+
``record_park`` row) once the close settles.
|
|
929
|
+
:ivar pending_since: ``time.time()`` at marker creation. Drives the
|
|
930
|
+
:meth:`OrderSyncEngine.reconcile` stale-pending grace check.
|
|
931
|
+
:ivar reject_context: Frozen snapshot of the
|
|
932
|
+
:class:`BracketAttachRejectContext` that produced the close —
|
|
933
|
+
used by startup replay to re-derive residual orders without
|
|
934
|
+
rebuilding the exception.
|
|
935
|
+
:ivar residual_cleanup_pending: ``True`` when the dispatch-time
|
|
936
|
+
residual-cancel call hit a transient
|
|
937
|
+
:class:`ExchangeConnectionError` /
|
|
938
|
+
:class:`OrderDispositionUnknownError` and could not finish
|
|
939
|
+
cancelling parent/TP/SL/partial-remainder orders. The next
|
|
940
|
+
:meth:`OrderSyncEngine.reconcile` replays
|
|
941
|
+
:meth:`OrderSyncEngine._cancel_bracket_reject_residuals` so the
|
|
942
|
+
residuals do not stay live until the FILL eventually arrives;
|
|
943
|
+
cleared once the retry finishes (or on FILL-time settlement,
|
|
944
|
+
whichever happens first).
|
|
945
|
+
:ivar fill_observed: ``True`` once
|
|
946
|
+
:meth:`OrderSyncEngine._route_defensive_close_fill` has matched
|
|
947
|
+
the defensive-close FILL event (and seeded the in-memory
|
|
948
|
+
duplicate-fill caches) but the final residual-cancel + audit
|
|
949
|
+
sequence has not yet completed. Persisted so that a crash in
|
|
950
|
+
the post-FILL settlement window does NOT lose the only record
|
|
951
|
+
that the close already filled — startup replay re-seeds the
|
|
952
|
+
duplicate-fill caches and routes the marker through the
|
|
953
|
+
FILL-side branch of
|
|
954
|
+
:meth:`OrderSyncEngine._retry_residual_cleanup_after_transient_fill`
|
|
955
|
+
rather than waiting for a FILL that will never re-arrive.
|
|
956
|
+
:ivar partial_filled_qty: Cumulative ``fill_qty`` already applied
|
|
957
|
+
to :attr:`OrderSyncEngine._position.size` by no-FIFO
|
|
958
|
+
defensive-close ``partial`` events. Set to zero on marker
|
|
959
|
+
creation; accumulates as partials arrive. Used by the terminal
|
|
960
|
+
``filled`` event's missing/zero ``fill_qty`` fallback to derive
|
|
961
|
+
the remaining close qty (``reject_context.qty -
|
|
962
|
+
partial_filled_qty``) instead of re-applying the full marker
|
|
963
|
+
qty (which would double-subtract the partial slices already
|
|
964
|
+
accounted for).
|
|
965
|
+
:ivar unapplied_partial_qty: Cumulative ``fill_qty`` from no-FIFO
|
|
966
|
+
defensive-close ``partial`` events that the engine could NOT
|
|
967
|
+
apply to :attr:`OrderSyncEngine._position.size` because the
|
|
968
|
+
parent ENTRY fill had not yet arrived (``_position.size ==
|
|
969
|
+
0.0``). Tracked separately from :attr:`partial_filled_qty` so
|
|
970
|
+
the terminal computation does not believe the slice was
|
|
971
|
+
already booked. When the parent ENTRY ``filled`` / ``partial``
|
|
972
|
+
event finally routes through
|
|
973
|
+
:meth:`OrderSyncEngine._route_event`, the engine subtracts the
|
|
974
|
+
signed accumulated qty from :attr:`_position.size` and moves
|
|
975
|
+
it onto :attr:`partial_filled_qty` so the terminal close FILL
|
|
976
|
+
sees a consistent state. ``0.0`` on marker creation and after
|
|
977
|
+
every drain.
|
|
978
|
+
:ivar fill_exchange_order_id: Broker-side ``order.id`` of the
|
|
979
|
+
actual FILL event observed by
|
|
980
|
+
:meth:`OrderSyncEngine._route_defensive_close_fill`. Persisted
|
|
981
|
+
alongside :attr:`fill_observed` so that startup replay can
|
|
982
|
+
re-seed :attr:`OrderSyncEngine._settled_defensive_close_order_refs`
|
|
983
|
+
with the fill id when the FILL arrived with an id different
|
|
984
|
+
from :attr:`close_order_ref` (polled-orders fallback / broker
|
|
985
|
+
rekey). Without persisting it, a delayed WS / polled-orders
|
|
986
|
+
replay of the same FILL post-restart whose
|
|
987
|
+
``event.order.id`` differs from ``close_order_ref`` and does
|
|
988
|
+
not echo ``close_client_order_id`` would slip past
|
|
989
|
+
:meth:`OrderSyncEngine._is_duplicate_defensive_close_fill` and
|
|
990
|
+
be re-applied to ``BrokerPosition``. ``None`` until the FILL
|
|
991
|
+
is observed (or when the FILL event carried no ``order``).
|
|
992
|
+
:ivar pre_close_position_size: Signed
|
|
993
|
+
:attr:`OrderSyncEngine._position.size` captured at marker
|
|
994
|
+
creation, before the defensive close dispatched. Used by
|
|
995
|
+
startup replay to distinguish a broker snapshot that still
|
|
996
|
+
reports the pre-close aggregate (close has not landed yet)
|
|
997
|
+
from one that already reflects the close (close landed before
|
|
998
|
+
crash but the FILL was not observed by the prior process —
|
|
999
|
+
``fill_observed`` would otherwise stay ``False``). Without it,
|
|
1000
|
+
the post-restart reconcile cannot safely adopt the broker's
|
|
1001
|
+
reduced-but-not-flat size into :attr:`_position.size` because
|
|
1002
|
+
the no-FIFO settle branch would treat the adopted size as
|
|
1003
|
+
pre-close and over-reduce when the delayed FILL routes.
|
|
1004
|
+
``None`` only for markers persisted by an older schema; the
|
|
1005
|
+
replay path falls back to the conservative "skip adoption"
|
|
1006
|
+
behaviour in that case.
|
|
1007
|
+
:ivar fifo_closed_entry_ids: Snapshot of the entry ids whose FIFO
|
|
1008
|
+
``Trade`` rows were closed by :meth:`record_fill` for this
|
|
1009
|
+
defensive close, accumulated across every ``partial`` /
|
|
1010
|
+
terminal ``filled`` event of the close sequence. The
|
|
1011
|
+
``t == 'partial'`` branch in
|
|
1012
|
+
:meth:`OrderSyncEngine._route_event` merges each partial's
|
|
1013
|
+
FIFO closures here, and
|
|
1014
|
+
:meth:`OrderSyncEngine._route_defensive_close_fill` merges the
|
|
1015
|
+
terminal slice on top BEFORE the residual-cancel attempt — so
|
|
1016
|
+
in pyramiding (LongA closed by a partial, LongB closed by the
|
|
1017
|
+
terminal) both entries are persisted, not only the terminal's.
|
|
1018
|
+
Persisted so that
|
|
1019
|
+
:meth:`OrderSyncEngine._retry_residual_cleanup_after_transient_fill`
|
|
1020
|
+
can run the same FIFO-aware
|
|
1021
|
+
:meth:`OrderSyncEngine._cleanup_position_tracking` walk as the
|
|
1022
|
+
in-flight FILL path even when intervening
|
|
1023
|
+
:meth:`record_fill` calls have overwritten the live
|
|
1024
|
+
``_last_fifo_closed_entry_ids`` snapshot. Empty when the FILL
|
|
1025
|
+
produced no FIFO closures (no-FIFO / degenerate path) or when
|
|
1026
|
+
the marker is replayed from an older schema; the retry path
|
|
1027
|
+
falls back to ``marker.entry_id`` in that case.
|
|
1028
|
+
"""
|
|
1029
|
+
entry_id: str
|
|
1030
|
+
close_intent_key: str
|
|
1031
|
+
close_order_ref: str | None
|
|
1032
|
+
pending_since: float
|
|
1033
|
+
reject_context: BracketAttachRejectContext
|
|
1034
|
+
close_client_order_id: str | None = None
|
|
1035
|
+
residual_cleanup_pending: bool = False
|
|
1036
|
+
fill_observed: bool = False
|
|
1037
|
+
partial_filled_qty: float = 0.0
|
|
1038
|
+
fill_exchange_order_id: str | None = None
|
|
1039
|
+
pre_close_position_size: float | None = None
|
|
1040
|
+
fifo_closed_entry_ids: tuple[str, ...] = ()
|
|
1041
|
+
unapplied_partial_qty: float = 0.0
|
|
1042
|
+
|
|
1043
|
+
def to_extras_dict(self) -> dict:
|
|
1044
|
+
"""Serialize to a JSON-compatible dict for ``extras`` storage."""
|
|
1045
|
+
return {
|
|
1046
|
+
'entry_id': self.entry_id,
|
|
1047
|
+
'close_intent_key': self.close_intent_key,
|
|
1048
|
+
'close_order_ref': self.close_order_ref,
|
|
1049
|
+
'close_client_order_id': self.close_client_order_id,
|
|
1050
|
+
'pending_since': self.pending_since,
|
|
1051
|
+
'reject_context': self.reject_context.to_dict(),
|
|
1052
|
+
'residual_cleanup_pending': self.residual_cleanup_pending,
|
|
1053
|
+
'fill_observed': self.fill_observed,
|
|
1054
|
+
'partial_filled_qty': self.partial_filled_qty,
|
|
1055
|
+
'fill_exchange_order_id': self.fill_exchange_order_id,
|
|
1056
|
+
'pre_close_position_size': self.pre_close_position_size,
|
|
1057
|
+
'fifo_closed_entry_ids': list(self.fifo_closed_entry_ids),
|
|
1058
|
+
'unapplied_partial_qty': self.unapplied_partial_qty,
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@classmethod
|
|
1062
|
+
def from_extras_dict(cls, data: dict) -> 'PendingDefensiveClose':
|
|
1063
|
+
"""Rebuild from a previously serialized ``to_extras_dict`` payload.
|
|
1064
|
+
|
|
1065
|
+
Raises :class:`ValueError` on malformed input — the startup
|
|
1066
|
+
replay catches this and logs + skips the row.
|
|
1067
|
+
"""
|
|
1068
|
+
if not isinstance(data, dict):
|
|
1069
|
+
raise ValueError(
|
|
1070
|
+
f"PendingDefensiveClose payload must be a dict, "
|
|
1071
|
+
f"got {type(data).__name__}"
|
|
1072
|
+
)
|
|
1073
|
+
try:
|
|
1074
|
+
entry_id = data['entry_id']
|
|
1075
|
+
close_intent_key = data['close_intent_key']
|
|
1076
|
+
pending_since = data['pending_since']
|
|
1077
|
+
ctx_payload = data['reject_context']
|
|
1078
|
+
except KeyError as exc:
|
|
1079
|
+
raise ValueError(
|
|
1080
|
+
f"PendingDefensiveClose payload missing required "
|
|
1081
|
+
f"field {exc.args[0]!r}: {data!r}"
|
|
1082
|
+
) from exc
|
|
1083
|
+
if not (isinstance(entry_id, str) and isinstance(close_intent_key, str)):
|
|
1084
|
+
raise ValueError(
|
|
1085
|
+
f"PendingDefensiveClose required string fields have "
|
|
1086
|
+
f"wrong type: {data!r}"
|
|
1087
|
+
)
|
|
1088
|
+
if not isinstance(pending_since, (int, float)):
|
|
1089
|
+
raise ValueError(
|
|
1090
|
+
f"PendingDefensiveClose.pending_since must be numeric: "
|
|
1091
|
+
f"{data!r}"
|
|
1092
|
+
)
|
|
1093
|
+
close_order_ref = data.get('close_order_ref')
|
|
1094
|
+
if close_order_ref is not None and not isinstance(close_order_ref, str):
|
|
1095
|
+
raise ValueError(
|
|
1096
|
+
f"PendingDefensiveClose.close_order_ref must be str or "
|
|
1097
|
+
f"None: {data!r}"
|
|
1098
|
+
)
|
|
1099
|
+
close_client_order_id = data.get('close_client_order_id')
|
|
1100
|
+
if (close_client_order_id is not None
|
|
1101
|
+
and not isinstance(close_client_order_id, str)):
|
|
1102
|
+
raise ValueError(
|
|
1103
|
+
f"PendingDefensiveClose.close_client_order_id must be str "
|
|
1104
|
+
f"or None: {data!r}"
|
|
1105
|
+
)
|
|
1106
|
+
residual_cleanup_pending = bool(
|
|
1107
|
+
data.get('residual_cleanup_pending', False),
|
|
1108
|
+
)
|
|
1109
|
+
fill_observed = bool(data.get('fill_observed', False))
|
|
1110
|
+
partial_filled_qty_raw = data.get('partial_filled_qty', 0.0)
|
|
1111
|
+
if not isinstance(partial_filled_qty_raw, (int, float)):
|
|
1112
|
+
raise ValueError(
|
|
1113
|
+
f"PendingDefensiveClose.partial_filled_qty must be "
|
|
1114
|
+
f"numeric: {data!r}"
|
|
1115
|
+
)
|
|
1116
|
+
partial_filled_qty = float(partial_filled_qty_raw)
|
|
1117
|
+
if partial_filled_qty < 0.0:
|
|
1118
|
+
raise ValueError(
|
|
1119
|
+
f"PendingDefensiveClose.partial_filled_qty must be "
|
|
1120
|
+
f"non-negative: {data!r}"
|
|
1121
|
+
)
|
|
1122
|
+
fill_exchange_order_id = data.get('fill_exchange_order_id')
|
|
1123
|
+
if (fill_exchange_order_id is not None
|
|
1124
|
+
and not isinstance(fill_exchange_order_id, str)):
|
|
1125
|
+
raise ValueError(
|
|
1126
|
+
f"PendingDefensiveClose.fill_exchange_order_id must be "
|
|
1127
|
+
f"str or None: {data!r}"
|
|
1128
|
+
)
|
|
1129
|
+
pre_close_position_size_raw = data.get('pre_close_position_size')
|
|
1130
|
+
if pre_close_position_size_raw is None:
|
|
1131
|
+
pre_close_position_size: float | None = None
|
|
1132
|
+
elif isinstance(pre_close_position_size_raw, (int, float)):
|
|
1133
|
+
pre_close_position_size = float(pre_close_position_size_raw)
|
|
1134
|
+
else:
|
|
1135
|
+
raise ValueError(
|
|
1136
|
+
f"PendingDefensiveClose.pre_close_position_size must be "
|
|
1137
|
+
f"numeric or None: {data!r}"
|
|
1138
|
+
)
|
|
1139
|
+
fifo_closed_entry_ids_raw = data.get('fifo_closed_entry_ids', [])
|
|
1140
|
+
if not isinstance(fifo_closed_entry_ids_raw, (list, tuple)):
|
|
1141
|
+
raise ValueError(
|
|
1142
|
+
f"PendingDefensiveClose.fifo_closed_entry_ids must be "
|
|
1143
|
+
f"a list or tuple: {data!r}"
|
|
1144
|
+
)
|
|
1145
|
+
fifo_closed_entry_ids_list: list[str] = []
|
|
1146
|
+
for entry in fifo_closed_entry_ids_raw:
|
|
1147
|
+
if not isinstance(entry, str):
|
|
1148
|
+
raise ValueError(
|
|
1149
|
+
f"PendingDefensiveClose.fifo_closed_entry_ids items "
|
|
1150
|
+
f"must be strings: {data!r}"
|
|
1151
|
+
)
|
|
1152
|
+
fifo_closed_entry_ids_list.append(entry)
|
|
1153
|
+
unapplied_partial_qty_raw = data.get('unapplied_partial_qty', 0.0)
|
|
1154
|
+
if not isinstance(unapplied_partial_qty_raw, (int, float)):
|
|
1155
|
+
raise ValueError(
|
|
1156
|
+
f"PendingDefensiveClose.unapplied_partial_qty must be "
|
|
1157
|
+
f"numeric: {data!r}"
|
|
1158
|
+
)
|
|
1159
|
+
unapplied_partial_qty = float(unapplied_partial_qty_raw)
|
|
1160
|
+
if unapplied_partial_qty < 0.0:
|
|
1161
|
+
raise ValueError(
|
|
1162
|
+
f"PendingDefensiveClose.unapplied_partial_qty must be "
|
|
1163
|
+
f"non-negative: {data!r}"
|
|
1164
|
+
)
|
|
1165
|
+
return cls(
|
|
1166
|
+
entry_id=entry_id,
|
|
1167
|
+
close_intent_key=close_intent_key,
|
|
1168
|
+
close_order_ref=close_order_ref,
|
|
1169
|
+
pending_since=float(pending_since),
|
|
1170
|
+
reject_context=BracketAttachRejectContext.from_dict(ctx_payload),
|
|
1171
|
+
close_client_order_id=close_client_order_id,
|
|
1172
|
+
residual_cleanup_pending=residual_cleanup_pending,
|
|
1173
|
+
fill_observed=fill_observed,
|
|
1174
|
+
partial_filled_qty=partial_filled_qty,
|
|
1175
|
+
fill_exchange_order_id=fill_exchange_order_id,
|
|
1176
|
+
pre_close_position_size=pre_close_position_size,
|
|
1177
|
+
fifo_closed_entry_ids=tuple(fifo_closed_entry_ids_list),
|
|
1178
|
+
unapplied_partial_qty=unapplied_partial_qty,
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
# === Dispatch envelope ===
|
|
1183
|
+
|
|
1184
|
+
@dataclass(frozen=True)
|
|
1185
|
+
class DispatchEnvelope:
|
|
1186
|
+
"""Broker dispatch envelope — an intent plus idempotency metadata.
|
|
1187
|
+
|
|
1188
|
+
The :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine` wraps every
|
|
1189
|
+
intent in a fresh envelope before handing it to the :class:`BrokerPlugin`.
|
|
1190
|
+
Plugins call :meth:`client_order_id` for each exchange order they place;
|
|
1191
|
+
the result is deterministic, so a retry or restart regenerates the same id
|
|
1192
|
+
and the exchange dedups the duplicate.
|
|
1193
|
+
|
|
1194
|
+
:ivar intent: The Pine-level intent this dispatch carries.
|
|
1195
|
+
:ivar run_tag: 4-char base36 session tag (see
|
|
1196
|
+
:meth:`~pynecore.core.broker.run_identity.RunIdentity.make_run_tag`).
|
|
1197
|
+
:ivar bar_ts_ms: Bar open timestamp (ms since Unix epoch).
|
|
1198
|
+
:ivar retry_seq: Bumped by the recovery path only when a prior attempt is
|
|
1199
|
+
deliberately abandoned — defaults to ``0``.
|
|
1200
|
+
:ivar coid_max_len: The venue's client-id budget (the plugin's
|
|
1201
|
+
:attr:`~pynecore.core.plugin.broker.BrokerPlugin.client_order_id_max_len`).
|
|
1202
|
+
When the canonical id exceeds it, :meth:`client_order_id` emits the
|
|
1203
|
+
deterministic wire form instead (see
|
|
1204
|
+
:mod:`pynecore.core.broker.idempotency`). The default keeps the
|
|
1205
|
+
canonical id untouched.
|
|
1206
|
+
"""
|
|
1207
|
+
intent: 'EntryIntent | ExitIntent | CloseIntent | CancelIntent'
|
|
1208
|
+
run_tag: str
|
|
1209
|
+
bar_ts_ms: int
|
|
1210
|
+
retry_seq: int = 0
|
|
1211
|
+
coid_max_len: int = CLIENT_ORDER_ID_MAX_LEN
|
|
1212
|
+
|
|
1213
|
+
def client_order_id(self, kind: str) -> str:
|
|
1214
|
+
"""Allocate the client-order-id for a given leg kind.
|
|
1215
|
+
|
|
1216
|
+
Canonical form, shortened to the venue's wire form when
|
|
1217
|
+
:attr:`coid_max_len` demands it — deterministic either way.
|
|
1218
|
+
|
|
1219
|
+
:param kind: One of the ``KIND_*`` constants from
|
|
1220
|
+
:mod:`pynecore.core.broker.idempotency`.
|
|
1221
|
+
"""
|
|
1222
|
+
return encode_wire_client_order_id(
|
|
1223
|
+
build_client_order_id(
|
|
1224
|
+
run_tag=self.run_tag,
|
|
1225
|
+
pine_id=self._coid_identity(),
|
|
1226
|
+
bar_ts_ms=self.bar_ts_ms,
|
|
1227
|
+
kind=kind,
|
|
1228
|
+
retry_seq=self.retry_seq,
|
|
1229
|
+
),
|
|
1230
|
+
self.coid_max_len,
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
def _coid_identity(self) -> str:
|
|
1234
|
+
"""Return the identity string hashed into the client-order-id.
|
|
1235
|
+
|
|
1236
|
+
For an :class:`EntryIntent` / :class:`CloseIntent` the Pine id alone
|
|
1237
|
+
uniquely identifies the dispatch. An :class:`ExitIntent` (and a
|
|
1238
|
+
per-entry :class:`CancelIntent`) shares one exit ``pine_id`` across
|
|
1239
|
+
every entry a global ``strategy.exit`` fans out to, so the id must
|
|
1240
|
+
also fold in ``from_entry`` — otherwise two distinct protective
|
|
1241
|
+
brackets on a pyramided position collapse onto the same
|
|
1242
|
+
deterministic coid, the venue dedups the second create, and half the
|
|
1243
|
+
position is left unprotected.
|
|
1244
|
+
"""
|
|
1245
|
+
from_entry = getattr(self.intent, 'from_entry', None)
|
|
1246
|
+
if from_entry is not None:
|
|
1247
|
+
return f"{self.intent.pine_id}{INTENT_KEY_SEP}{from_entry}"
|
|
1248
|
+
return self.intent.pine_id
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
# === Compile-time detected script requirements ===
|
|
1252
|
+
|
|
1253
|
+
@dataclass
|
|
1254
|
+
class ScriptRequirements:
|
|
1255
|
+
"""Broker capabilities needed by this script. Detected via AST analysis."""
|
|
1256
|
+
market_orders: bool = False
|
|
1257
|
+
limit_orders: bool = False
|
|
1258
|
+
stop_orders: bool = False
|
|
1259
|
+
tp_sl_bracket: bool = False # strategy.exit() with BOTH limit+stop or profit+loss
|
|
1260
|
+
trailing_stop: bool = False
|
|
1261
|
+
strategy_order: bool = False # strategy.order() — no pyramiding limit
|
|
1262
|
+
# True if the script calls any of ``strategy.exit`` / ``strategy.close`` /
|
|
1263
|
+
# ``strategy.close_all``. Every such call requires the exchange to honour
|
|
1264
|
+
# reduce-only semantics — a manual position close otherwise lets the
|
|
1265
|
+
# still-pending exit flip the book the other way. The validator turns
|
|
1266
|
+
# this into a hard reject when ``caps.reduce_only=False``.
|
|
1267
|
+
exit_orders: bool = False
|
|
1268
|
+
# True if the script calls ``strategy.exit(qty=N, from_entry="L", ...)``
|
|
1269
|
+
# with ``N < total qty entered under "L"`` AND includes any bracket-leg
|
|
1270
|
+
# parameters (``limit=``/``stop=``/``profit=``/``loss=``/``trail_*=``).
|
|
1271
|
+
# The validator rejects the script at startup when
|
|
1272
|
+
# ``caps.partial_qty_bracket_exit=False`` — position-attribute bracket
|
|
1273
|
+
# exchanges (Capital.com) can only attach bracket to the whole row, not
|
|
1274
|
+
# a partial quantity, and silently covering the full qty would be a
|
|
1275
|
+
# safety violation.
|
|
1276
|
+
partial_qty_bracket_exit: bool = False
|
|
1277
|
+
# True if any ``strategy.entry`` / ``strategy.order`` call passes a
|
|
1278
|
+
# syntactically constant ``strategy.short`` direction (raw, ``lib.``-
|
|
1279
|
+
# normalized, or ``strategy.direction.short`` spelled out). The validator
|
|
1280
|
+
# rejects the script at startup when ``caps.short_selling`` is
|
|
1281
|
+
# UNSUPPORTED. A dynamic direction (variable, conditional expression)
|
|
1282
|
+
# cannot be proven at compile time and leaves the flag ``False`` — the
|
|
1283
|
+
# detection is advisory only; the sync engine's projected-position gate
|
|
1284
|
+
# is the authoritative runtime guard on short-incapable venues.
|
|
1285
|
+
may_go_short: bool = False
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# === Interceptor (Order Sync Engine extension point) ===
|
|
1289
|
+
|
|
1290
|
+
# === Broker events (observability) =======================================
|
|
1291
|
+
|
|
1292
|
+
@dataclass
|
|
1293
|
+
class BrokerEvent:
|
|
1294
|
+
"""Base class for structured broker-side events.
|
|
1295
|
+
|
|
1296
|
+
The plugin emits these via an injected callback so the runner can
|
|
1297
|
+
surface them in logs, metrics, and the user-facing event stream
|
|
1298
|
+
without the plugin coupling to any specific sink.
|
|
1299
|
+
"""
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
@dataclass
|
|
1303
|
+
class AuthenticationFailedEvent(BrokerEvent):
|
|
1304
|
+
"""Emitted when the plugin's credentials are rejected by the exchange.
|
|
1305
|
+
|
|
1306
|
+
``reason`` is the short human-readable cause (``AuthenticationError.reason``);
|
|
1307
|
+
the runner surfaces the event to observability sinks and then performs a
|
|
1308
|
+
graceful stop — reconnect cannot gain access with wrong credentials.
|
|
1309
|
+
"""
|
|
1310
|
+
reason: str
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
@dataclass
|
|
1314
|
+
class BracketRegisteredEvent(BrokerEvent):
|
|
1315
|
+
pine_id: str
|
|
1316
|
+
from_entry: str
|
|
1317
|
+
tp_order_id: str | None
|
|
1318
|
+
sl_order_id: str | None
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
@dataclass
|
|
1322
|
+
class LegPartialRepairedEvent(BrokerEvent):
|
|
1323
|
+
pine_id: str
|
|
1324
|
+
from_entry: str
|
|
1325
|
+
leg: str # "tp" | "sl"
|
|
1326
|
+
generation: int
|
|
1327
|
+
old_qty: float
|
|
1328
|
+
new_qty: float
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
@dataclass
|
|
1332
|
+
class LegRepairFailedEvent(BrokerEvent):
|
|
1333
|
+
pine_id: str
|
|
1334
|
+
from_entry: str
|
|
1335
|
+
leg: str # "tp" | "sl"
|
|
1336
|
+
reason: str
|
|
1337
|
+
action_taken: str # "degraded" | "retry" | ...
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
@dataclass
|
|
1341
|
+
class BracketReconstructedEvent(BrokerEvent):
|
|
1342
|
+
pine_id: str
|
|
1343
|
+
from_entry: str
|
|
1344
|
+
source: str # "open_orders" | "position_snapshot" | ...
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
@dataclass
|
|
1348
|
+
class ProtectionDegradedEvent(BrokerEvent):
|
|
1349
|
+
"""The bracket can no longer be maintained with OCA reduce semantics.
|
|
1350
|
+
|
|
1351
|
+
``reason`` is human-readable / diagnostic; ``policy_action`` names the
|
|
1352
|
+
manager's chosen follow-up (``"degraded"`` → bracket left in place but
|
|
1353
|
+
unsupervised; ``"terminal"`` → bracket closed out).
|
|
1354
|
+
"""
|
|
1355
|
+
pine_id: str
|
|
1356
|
+
from_entry: str
|
|
1357
|
+
reason: str
|
|
1358
|
+
policy_action: str
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
@dataclass
|
|
1362
|
+
class ManualInterventionRequiredEvent(BrokerEvent):
|
|
1363
|
+
"""Emitted when the sync engine halts because the plugin raised
|
|
1364
|
+
:class:`~pynecore.core.broker.exceptions.BrokerManualInterventionError`.
|
|
1365
|
+
|
|
1366
|
+
Surfaces the operator-actionable details (reason, optional intent key,
|
|
1367
|
+
plugin-supplied diagnostic context) so the observability bus, on-call
|
|
1368
|
+
alerting, and the user-facing event stream can page without the runner
|
|
1369
|
+
needing to reach into plugin internals. After this event fires the
|
|
1370
|
+
engine is halted — all subsequent :meth:`sync` calls return early until
|
|
1371
|
+
the strategy is restarted.
|
|
1372
|
+
"""
|
|
1373
|
+
reason: str
|
|
1374
|
+
intent_key: str | None = None
|
|
1375
|
+
context: dict | None = None
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
@dataclass
|
|
1379
|
+
class QuarantineEnteredEvent(BrokerEvent):
|
|
1380
|
+
"""Emitted once when the sync engine latches its quarantine state.
|
|
1381
|
+
|
|
1382
|
+
Quarantine stops trading without stopping the bot: new and
|
|
1383
|
+
exposure-increasing dispatches (entry orders, entry amends) are
|
|
1384
|
+
blocked, while event ingestion, protective exits, cancels and closes
|
|
1385
|
+
keep working — the process stays a live observer of its open
|
|
1386
|
+
exposure. The operator resolves the underlying cause and restarts the
|
|
1387
|
+
strategy; the engine never leaves quarantine on its own.
|
|
1388
|
+
"""
|
|
1389
|
+
reason: str
|
|
1390
|
+
intent_key: str | None = None
|
|
1391
|
+
context: dict | None = None
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
@dataclass
|
|
1395
|
+
class NativeFailsafeStateTransitionEvent(BrokerEvent):
|
|
1396
|
+
"""The §2.6.7 ``NativeStopState`` for ``parent_entry_dispatch_ref`` changed
|
|
1397
|
+
health state.
|
|
1398
|
+
|
|
1399
|
+
Emitted on every ``healthy ↔ degrading ↔ degraded ↔ retired`` transition so
|
|
1400
|
+
observability sinks see the full lifecycle, not only terminal failures.
|
|
1401
|
+
"""
|
|
1402
|
+
parent_entry_dispatch_ref: str
|
|
1403
|
+
symbol: str
|
|
1404
|
+
from_state: str # 'healthy' | 'degrading' | 'degraded' | 'retired'
|
|
1405
|
+
to_state: str
|
|
1406
|
+
reason: str
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
@dataclass
|
|
1410
|
+
class PartialBracketBlockedDegradedFailsafeEvent(BrokerEvent):
|
|
1411
|
+
"""A new SOFTWARE partial-qty bracket dispatch was rejected because
|
|
1412
|
+
the parent's ``NativeStopState`` is ``degrading`` / ``degraded``.
|
|
1413
|
+
|
|
1414
|
+
The engine still services *close* and *intermediate-leg* dispatches for the
|
|
1415
|
+
same parent — only *new* partial brackets are blocked while the broker-native
|
|
1416
|
+
fail-safe cannot be verified present.
|
|
1417
|
+
"""
|
|
1418
|
+
parent_entry_dispatch_ref: str
|
|
1419
|
+
symbol: str
|
|
1420
|
+
pine_id: str
|
|
1421
|
+
from_entry: str
|
|
1422
|
+
health: str # 'degrading' | 'degraded'
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
@dataclass
|
|
1426
|
+
class EntryBlockedDegradedFailsafeEvent(BrokerEvent):
|
|
1427
|
+
"""A new ``strategy.entry`` on a symbol that has at least one
|
|
1428
|
+
``degrading`` / ``degraded`` ``NativeStopState`` was rejected (§2.6.7).
|
|
1429
|
+
|
|
1430
|
+
The script's signal is dropped rather than queued — see
|
|
1431
|
+
:class:`EntrySkippedDueToDegradedFailsafeEvent` for the replay-policy
|
|
1432
|
+
consequence.
|
|
1433
|
+
"""
|
|
1434
|
+
symbol: str
|
|
1435
|
+
pine_id: str
|
|
1436
|
+
health: str # 'degrading' | 'degraded'
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
@dataclass
|
|
1440
|
+
class EntrySkippedDueToDegradedFailsafeEvent(BrokerEvent):
|
|
1441
|
+
"""Drop-semantics counterpart of
|
|
1442
|
+
:class:`EntryBlockedDegradedFailsafeEvent`: the entry signal is NOT
|
|
1443
|
+
queued and will NOT be replayed when the failsafe recovers (§2.6.7).
|
|
1444
|
+
"""
|
|
1445
|
+
symbol: str
|
|
1446
|
+
pine_id: str
|
|
1447
|
+
bar_ts_ms: int
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
@dataclass
|
|
1451
|
+
class BrokerNativeFailsafeUnavailableEvent(BrokerEvent):
|
|
1452
|
+
"""The native fail-safe stop on ``parent_entry_dispatch_ref`` has dropped
|
|
1453
|
+
to ``degraded`` — the PUT retry budget is exhausted or the stale window
|
|
1454
|
+
expired (§2.6). Until a user reset / ``set_risk`` arrives, the engine
|
|
1455
|
+
will not re-issue native bracket amends for this parent.
|
|
1456
|
+
"""
|
|
1457
|
+
parent_entry_dispatch_ref: str
|
|
1458
|
+
symbol: str
|
|
1459
|
+
reason: str
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
@dataclass
|
|
1463
|
+
class BrokerNativeFailsafeExternalEditEvent(BrokerEvent):
|
|
1464
|
+
"""Snapshot diff detected ``actual_level != desired_level`` with no
|
|
1465
|
+
pending PUT in flight (§2.6.7). Owner flipped to ``unknown`` — the
|
|
1466
|
+
engine will not overwrite until the user explicitly resets ownership.
|
|
1467
|
+
"""
|
|
1468
|
+
parent_entry_dispatch_ref: str
|
|
1469
|
+
symbol: str
|
|
1470
|
+
desired_level: float | None
|
|
1471
|
+
actual_level: float | None
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
@dataclass
|
|
1475
|
+
class BrokerNativeFailsafeFullCloseEvent(BrokerEvent):
|
|
1476
|
+
"""The broker-native worst-SL stop on the parent fired and closed the
|
|
1477
|
+
full residual (§3.4 cascade). Every remaining engine-trigger partial
|
|
1478
|
+
leg under this parent is cascaded into ``cascaded_cancel_by_native_sl``
|
|
1479
|
+
(snapshot-driven, idempotent on ``dealId + generation``).
|
|
1480
|
+
"""
|
|
1481
|
+
parent_entry_dispatch_ref: str
|
|
1482
|
+
symbol: str
|
|
1483
|
+
actual_level: float | None
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
class CancelDispositionOutcome(StrEnum):
|
|
1487
|
+
"""Normalized outcome of a broker-side cancel attempt.
|
|
1488
|
+
|
|
1489
|
+
Returned by :meth:`BrokerPlugin.execute_cancel_with_outcome` and used by
|
|
1490
|
+
the sync engine's ``reconcile()`` cancel-retry-loop to drive the
|
|
1491
|
+
cancel-tentative state machine forward. The five outcomes encode the
|
|
1492
|
+
only decision-relevant disposition categories — broker-specific status
|
|
1493
|
+
strings, HTTP codes, and exception types are normalized into these by
|
|
1494
|
+
each plugin's override.
|
|
1495
|
+
|
|
1496
|
+
See the cancel-tentative state design dossier §2.6 for the resolution
|
|
1497
|
+
table that maps each outcome to a leg state transition.
|
|
1498
|
+
"""
|
|
1499
|
+
# Broker explicitly confirmed the cancel landed (live order →
|
|
1500
|
+
# cancelled, no fill). Sync engine: confirm-cancel-tentative,
|
|
1501
|
+
# flip legs to ``aborted_parent_never_arrived``, drop mapping.
|
|
1502
|
+
CANCEL_CONFIRMED = 'cancel_confirmed'
|
|
1503
|
+
# Broker explicitly reported that the order had already filled
|
|
1504
|
+
# before / during the cancel attempt (race lost). Sync engine:
|
|
1505
|
+
# restore legs from ``cancel_tentative``, re-register parent.
|
|
1506
|
+
ALREADY_FILLED = 'already_filled'
|
|
1507
|
+
# Broker explicitly reported that the order can no longer be
|
|
1508
|
+
# cancelled (e.g., past its execution window) AND that no fill
|
|
1509
|
+
# occurred. Treated as ``CANCEL_CONFIRMED`` by the sync engine —
|
|
1510
|
+
# the disposition is unambiguous, only the wording differs.
|
|
1511
|
+
TOO_LATE_TO_CANCEL = 'too_late_to_cancel'
|
|
1512
|
+
# A fresh ``execute_cancel`` round succeeded (i.e., the order was
|
|
1513
|
+
# in fact still live and is now cancelled — distinct from
|
|
1514
|
+
# CANCEL_CONFIRMED in that the *previous* attempt's disposition
|
|
1515
|
+
# was unknown; this one is unambiguous). Treated as
|
|
1516
|
+
# ``CANCEL_CONFIRMED`` by the sync engine.
|
|
1517
|
+
STILL_OPEN = 'still_open'
|
|
1518
|
+
# Plugin could not disambiguate (404 / not-found / timeout /
|
|
1519
|
+
# ambiguous response). Sync engine: leg stays
|
|
1520
|
+
# ``cancel_tentative``, retry on next reconcile until stale-grace.
|
|
1521
|
+
UNKNOWN = 'unknown'
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
@dataclass
|
|
1525
|
+
class PartialBracketCancelTentativeStartedEvent(BrokerEvent):
|
|
1526
|
+
"""A pending-entry partial bracket leg entered ``cancel_tentative``
|
|
1527
|
+
because :meth:`BrokerPlugin.execute_cancel` raised
|
|
1528
|
+
:class:`OrderDispositionUnknownError`.
|
|
1529
|
+
|
|
1530
|
+
The sync engine retains the order mapping and envelope; the
|
|
1531
|
+
``reconcile()`` cancel-retry-loop drives the leg to resolution within
|
|
1532
|
+
the stale-grace window. See the cancel-tentative state design dossier.
|
|
1533
|
+
"""
|
|
1534
|
+
intent_key: str
|
|
1535
|
+
reason: str
|
|
1536
|
+
since_ts_ms: int
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
@dataclass
|
|
1540
|
+
class PartialBracketCancelTentativeResolvedEvent(BrokerEvent):
|
|
1541
|
+
"""A ``cancel_tentative`` leg has been resolved — either confirmed
|
|
1542
|
+
cancelled (leg ``aborted_parent_never_arrived``) or restored (leg
|
|
1543
|
+
back to ``pending_entry`` / ``armed``) following a broker disposition
|
|
1544
|
+
outcome or a late parent fill event.
|
|
1545
|
+
|
|
1546
|
+
``outcome`` reflects the resolution reason; ``via`` indicates which
|
|
1547
|
+
code path delivered it (``reconcile_retry`` or ``order_event``).
|
|
1548
|
+
"""
|
|
1549
|
+
intent_key: str
|
|
1550
|
+
outcome: CancelDispositionOutcome
|
|
1551
|
+
via: str # 'reconcile_retry' | 'order_event'
|
|
1552
|
+
duration_ms: int
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
@dataclass
|
|
1556
|
+
class PartialBracketCancelTentativeDegradedEvent(BrokerEvent):
|
|
1557
|
+
"""The stale-grace deadline (default 10s) for a ``cancel_tentative``
|
|
1558
|
+
intent expired without resolution.
|
|
1559
|
+
|
|
1560
|
+
The parent dispatch is promoted to ``DEGRADED_HALT``; the script's
|
|
1561
|
+
further entries on the symbol are blocked until manual intervention
|
|
1562
|
+
via :class:`ManualInterventionRequiredEvent`.
|
|
1563
|
+
"""
|
|
1564
|
+
intent_key: str
|
|
1565
|
+
symbol: str
|
|
1566
|
+
since_ts_ms: int
|
|
1567
|
+
stale_grace_ms: int
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
@dataclass
|
|
1571
|
+
class EntryDeferredCancelDispositionPendingEvent(BrokerEvent):
|
|
1572
|
+
"""The diff-loop's adoption path saw a fresh ``EntryIntent`` whose
|
|
1573
|
+
``intent_key`` is currently in ``cancel_disposition_pending``.
|
|
1574
|
+
|
|
1575
|
+
The new intent is NOT dispatched and NOT adopted — the prior
|
|
1576
|
+
dispatch's disposition is still unresolved, and an in-flight broker
|
|
1577
|
+
state would create a double-life. The script's signal will retry on
|
|
1578
|
+
the next ``sync()`` once the cancel disposition is resolved or
|
|
1579
|
+
promoted to ``DEGRADED_HALT``.
|
|
1580
|
+
"""
|
|
1581
|
+
intent_key: str
|
|
1582
|
+
pine_id: str
|
|
1583
|
+
symbol: str
|
|
1584
|
+
since_ts_ms: int
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
@dataclass
|
|
1588
|
+
class InterceptorResult:
|
|
1589
|
+
"""
|
|
1590
|
+
Interceptor decision on an intent — modifiable before execution.
|
|
1591
|
+
|
|
1592
|
+
Intent objects are frozen, so modifications are expressed as override
|
|
1593
|
+
fields on this result rather than by mutating the intent in place.
|
|
1594
|
+
"""
|
|
1595
|
+
intent: EntryIntent | ExitIntent | CloseIntent | CancelIntent
|
|
1596
|
+
rejected: bool = False
|
|
1597
|
+
reject_reason: str = ""
|
|
1598
|
+
modified_qty: float | None = None
|
|
1599
|
+
modified_limit: float | None = None
|
|
1600
|
+
modified_stop: float | None = None
|