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,781 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:class:`BrokerPlugin` — high-level order execution layer.
|
|
3
|
+
|
|
4
|
+
A broker plugin receives Pine Script *intents* (entry, exit bracket, close,
|
|
5
|
+
cancel) and translates them to exchange-specific orders. The plugin author
|
|
6
|
+
decides HOW: native brackets, separate orders with software monitoring,
|
|
7
|
+
``reduce_only`` flags, editOrder vs cancel-and-replace, etc.
|
|
8
|
+
|
|
9
|
+
Intents carry full Pine Script identity (``pine_id``, ``from_entry``,
|
|
10
|
+
``oca_name``) so the plugin can track order lifecycle and the sync engine
|
|
11
|
+
can route :class:`OrderEvent` fills back to the correct Pine trade.
|
|
12
|
+
|
|
13
|
+
See ``docs/pynecore/plugin-system/broker-plugin-plan.md`` for the full
|
|
14
|
+
design, in particular the rationale for the high-level intent API.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from collections.abc import AsyncIterator, Callable
|
|
20
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar
|
|
21
|
+
|
|
22
|
+
from pynecore.core.plugin.live_provider import LiveProviderConfig, LiveProviderPlugin
|
|
23
|
+
from pynecore.core.broker.exceptions import (
|
|
24
|
+
BrokerError,
|
|
25
|
+
ExchangeCapabilityError,
|
|
26
|
+
ExchangeConnectionError,
|
|
27
|
+
OrderDispositionUnknownError,
|
|
28
|
+
)
|
|
29
|
+
from pynecore.core.broker.idempotency import CLIENT_ORDER_ID_MAX_LEN
|
|
30
|
+
from pynecore.core.broker.models import (
|
|
31
|
+
CancelDispositionOutcome,
|
|
32
|
+
CancelIntent,
|
|
33
|
+
DispatchEnvelope,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from pynecore.core.broker.spot_inventory import SpotInventoryPort
|
|
38
|
+
from pynecore.core.broker.storage import RunContext
|
|
39
|
+
from pynecore.core.broker.models import (
|
|
40
|
+
BracketAttachRejectContext,
|
|
41
|
+
ExchangeOrder,
|
|
42
|
+
ExchangePosition,
|
|
43
|
+
ExchangeCapabilities,
|
|
44
|
+
OrderEvent,
|
|
45
|
+
PositionLeg,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = ['BrokerPlugin', 'PositionPort']
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PositionPort(Protocol):
|
|
52
|
+
"""Thin transport a one-way-emulating broker plugin exposes to core.
|
|
53
|
+
|
|
54
|
+
A hedging-mode account holds several broker positions ("legs") per symbol;
|
|
55
|
+
Pine sees a single *one-way* position. A plugin that opts into core one-way
|
|
56
|
+
emulation sets :attr:`BrokerPlugin.position_port` to an object (usually
|
|
57
|
+
``self``) implementing these primitives. The core
|
|
58
|
+
:class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` owns all
|
|
59
|
+
netting / FIFO / crash-replay logic and drives the plugin purely through
|
|
60
|
+
this surface — each method sends or reads exactly ONE broker entity.
|
|
61
|
+
Plugins that do not emulate (netting-native, or hedging-rejecting) leave
|
|
62
|
+
``position_port`` ``None`` and the engine uses the regular ``execute_*``
|
|
63
|
+
path unchanged.
|
|
64
|
+
|
|
65
|
+
The surface grows per emulation feature (close, then reversal, then bracket
|
|
66
|
+
replication); only the methods a wired feature needs are required.
|
|
67
|
+
|
|
68
|
+
Optional capability attribute — read via ``getattr``, absence means
|
|
69
|
+
``True``:
|
|
70
|
+
|
|
71
|
+
* ``supports_partial_leg_close`` — ``False`` when the venue cannot reduce
|
|
72
|
+
a single leg by a partial volume (e.g. Capital.com, whose position
|
|
73
|
+
DELETE is full-row only). The emulator then pre-flights every close /
|
|
74
|
+
reversal plan and atomically skips
|
|
75
|
+
(:class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`) any
|
|
76
|
+
plan containing a partial leg slice, before persisting or dispatching
|
|
77
|
+
anything.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
async def fetch_raw_positions(self, symbol: str) -> list[PositionLeg]:
|
|
81
|
+
"""All open legs for ``symbol`` (any direction), oldest first. No
|
|
82
|
+
aggregation — the core emulator nets them."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
async def get_volume_quantizer(self, symbol: str) -> Callable[[float], int]:
|
|
86
|
+
"""A sync ``Pine-units -> broker-grid-int`` quantizer for ``symbol``.
|
|
87
|
+
|
|
88
|
+
Returned as a closure so the emulator can snap per-leg volumes in a
|
|
89
|
+
tight loop without an await each call; the plugin owns the broker unit
|
|
90
|
+
(e.g. cTrader centi-units snapped to ``stepVolume``).
|
|
91
|
+
"""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
async def close_leg(
|
|
95
|
+
self, symbol: str, leg_id: str, volume: int, coid: str,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Send ONE reduce/close of ``volume`` (broker-grid units) on broker
|
|
98
|
+
leg ``leg_id`` under client-order-id ``coid``. The resulting fill
|
|
99
|
+
arrives on the regular order-event stream; this just dispatches."""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
async def reject_out_of_range(
|
|
103
|
+
self, envelope: 'DispatchEnvelope', qty: float,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Raise the broker's non-halting volume-bounds skip
|
|
106
|
+
(:class:`~pynecore.core.broker.exceptions.OrderSkippedByPlugin`) when
|
|
107
|
+
``qty`` (Pine units) is below the minimum or above the maximum tradable
|
|
108
|
+
size; return ``None`` when it is in range.
|
|
109
|
+
|
|
110
|
+
The core emulator calls this BEFORE a reversal's leg closes so an
|
|
111
|
+
out-of-range residual skips the whole reversal while that is still true,
|
|
112
|
+
never leaving the book half-reduced.
|
|
113
|
+
"""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
async def place_leg(
|
|
117
|
+
self, envelope: 'DispatchEnvelope', qty: float,
|
|
118
|
+
) -> list[ExchangeOrder]:
|
|
119
|
+
"""Open ONE order of ``qty`` (Pine units) for the envelope's
|
|
120
|
+
:class:`EntryIntent` — the residual leg of a reversal, or a plain add.
|
|
121
|
+
Returns the resulting :class:`ExchangeOrder`(s)."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
async def amend_bracket(
|
|
125
|
+
self, symbol: str, leg_id: str, *,
|
|
126
|
+
side: str,
|
|
127
|
+
tp_price: float | None,
|
|
128
|
+
sl_price: float | None,
|
|
129
|
+
trail_offset: float | None,
|
|
130
|
+
coid: str,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Replicate (or clear) a protective bracket on ONE broker leg.
|
|
133
|
+
|
|
134
|
+
Sets the take-profit / stop-loss / trailing levels of the exit's bracket
|
|
135
|
+
on broker leg ``leg_id`` under client-order-id ``coid``. Levels are in
|
|
136
|
+
PINE UNITS (absolute prices for ``tp_price`` / ``sl_price``, a price
|
|
137
|
+
distance for ``trail_offset``), exactly as the :class:`ExitIntent`
|
|
138
|
+
carries them — the plugin owns the conversion to its broker grid and any
|
|
139
|
+
broker-specific reduction (e.g. cTrader has no numeric trailing offset,
|
|
140
|
+
so the plugin seeds an absolute trailing anchor and a trailing flag).
|
|
141
|
+
``side`` is the exit side, supplied because a trail-only bracket needs it
|
|
142
|
+
to place the initial anchor on the correct side.
|
|
143
|
+
|
|
144
|
+
Passing ``tp_price`` / ``sl_price`` / ``trail_offset`` all ``None``
|
|
145
|
+
CLEARS the bracket on that one leg: on venues like cTrader the protection
|
|
146
|
+
is a single position attribute that an amend overwrites wholesale, so an
|
|
147
|
+
empty amend wipes it. The core emulator drives this per leg from its
|
|
148
|
+
ownership index, so a clear touches only the legs the cancelled exit owns
|
|
149
|
+
(never the whole position side).
|
|
150
|
+
|
|
151
|
+
Must be idempotent on ``coid`` (a restart re-amend with the same levels
|
|
152
|
+
is a broker no-op) and must NOT halt the bot on a leg-already-gone race —
|
|
153
|
+
a ``*_NOT_FOUND`` response normalises to a benign return. A genuine
|
|
154
|
+
rejection propagates as
|
|
155
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError` for
|
|
156
|
+
the caller to surface.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
BrokerConfigT = TypeVar('BrokerConfigT', bound=LiveProviderConfig)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BrokerPlugin(LiveProviderPlugin[BrokerConfigT], ABC):
|
|
165
|
+
"""
|
|
166
|
+
High-level order execution layer.
|
|
167
|
+
|
|
168
|
+
Subclasses implement the ``execute_*`` methods in whatever way their
|
|
169
|
+
exchange supports. The Order Sync Engine only calls these methods and
|
|
170
|
+
routes back the :class:`OrderEvent` objects they produce — it never
|
|
171
|
+
reaches into exchange-specific APIs itself.
|
|
172
|
+
|
|
173
|
+
**Bot-owned-order disappearance detection is a plugin responsibility.**
|
|
174
|
+
The engine deliberately does not diff its in-memory order mapping
|
|
175
|
+
against :meth:`get_open_orders`, because the resource namespaces are
|
|
176
|
+
broker-specific: a Pine entry may live as a working order, an open
|
|
177
|
+
position, or a position-attached bracket on different exchanges, and
|
|
178
|
+
``get_open_orders`` only covers one of those namespaces. The plugin must
|
|
179
|
+
therefore detect manual closes / broker-side liquidations / silent
|
|
180
|
+
cancels itself (typically a per-poll snapshot of *all* relevant
|
|
181
|
+
namespaces, with a small grace window to absorb in-flight races) and
|
|
182
|
+
report disappearance through :meth:`watch_orders` — either by emitting
|
|
183
|
+
a synthesised ``cancelled`` :class:`OrderEvent` (the engine's event
|
|
184
|
+
router cleans it out of internal tracking) or by raising
|
|
185
|
+
:class:`~pynecore.core.broker.exceptions.UnexpectedCancelError`
|
|
186
|
+
according to the configured :attr:`on_unexpected_cancel` policy. See
|
|
187
|
+
the Capital.com plugin's ``_reconcile_snapshot`` +
|
|
188
|
+
``_emit_unexpected_cancellations`` for the reference implementation.
|
|
189
|
+
|
|
190
|
+
**Transient-fault contract.** A plugin classifies connectivity faults into
|
|
191
|
+
the broker taxonomy: :class:`~pynecore.core.broker.exceptions.ExchangeConnectionError`
|
|
192
|
+
for a drop the engine recovers from by reconnecting, and
|
|
193
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError` for a
|
|
194
|
+
write whose acknowledgement was lost in flight (the engine parks it and
|
|
195
|
+
matches against :meth:`get_open_orders`). The Order Sync Engine adds a central
|
|
196
|
+
safety net for a plugin that lets a *raw* transient escape — a retryable
|
|
197
|
+
:class:`~pynecore.core.plugin.ProviderError` (transient wire faults should
|
|
198
|
+
subclass it) or a stdlib :class:`ConnectionError` / :class:`TimeoutError`. The
|
|
199
|
+
net behaves differently on a read than on a write:
|
|
200
|
+
|
|
201
|
+
- On a per-bar state read (:meth:`get_position` / :meth:`get_open_orders`)
|
|
202
|
+
the untranslated transient — retryable ``ProviderError``,
|
|
203
|
+
``ConnectionError`` or ``TimeoutError`` — is treated as
|
|
204
|
+
``ExchangeConnectionError`` and parked: reads are idempotent, so the
|
|
205
|
+
retry-next-bar is always safe. The one-shot startup :meth:`get_balance`
|
|
206
|
+
auth probe is outside this net (it runs before the per-bar engine loop, so
|
|
207
|
+
there is no cycle to park onto); a raw transient there aborts startup.
|
|
208
|
+
- On a direct order write (``execute_*`` / ``modify_*`` /
|
|
209
|
+
:meth:`cancel_broker_order_ref`) it is unrecoverable centrally — the engine
|
|
210
|
+
cannot tell a never-sent request from a landed one — so an untranslated
|
|
211
|
+
retryable ``ProviderError`` or ``ConnectionError`` raises
|
|
212
|
+
:class:`~pynecore.core.broker.exceptions.BrokerManualInterventionError` (a
|
|
213
|
+
controlled halt) rather than risk a duplicate fill. A raw write-side
|
|
214
|
+
``TimeoutError`` is the exception: the dispatch bridge cannot cancel the
|
|
215
|
+
still-running coroutine, so the order may yet land and the timeout stays
|
|
216
|
+
fatal rather than being latched as a halt.
|
|
217
|
+
|
|
218
|
+
The net only catches contract violations; a plugin SHOULD still classify
|
|
219
|
+
explicitly, which keeps the recoverable read path parking and the ambiguous
|
|
220
|
+
write path parked-for-verification instead of halting.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
client_order_id_max_len: int = CLIENT_ORDER_ID_MAX_LEN
|
|
224
|
+
"""
|
|
225
|
+
The venue's client-order-id length budget, in characters.
|
|
226
|
+
|
|
227
|
+
The default (30) is the canonical
|
|
228
|
+
:mod:`~pynecore.core.broker.idempotency` width and fits every currently
|
|
229
|
+
supported exchange. A plugin for a venue with a shorter client-id field
|
|
230
|
+
(some FIX implementations cap ``ClOrdID`` at 20) overrides this with the
|
|
231
|
+
venue's actual limit; the engine then mints deterministic wire-form ids
|
|
232
|
+
of exactly this length (see the idempotency module docstring). Must be
|
|
233
|
+
at least ``WIRE_CLIENT_ORDER_ID_MIN_LEN`` (20) — the startup contract
|
|
234
|
+
probe (:func:`~pynecore.core.broker.validation.validate_plugin_contract`)
|
|
235
|
+
rejects anything lower.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
defensive_close_resolution_grace_s: float | None = None
|
|
239
|
+
"""
|
|
240
|
+
Grace window (seconds) for a defensive close FILL to settle after
|
|
241
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._handle_bracket_attach_after_fill_reject`
|
|
242
|
+
dispatches it. The engine halts the run when a pending marker
|
|
243
|
+
survives this window — the missing FILL means an operator must
|
|
244
|
+
intervene.
|
|
245
|
+
|
|
246
|
+
``None`` (default) uses the engine's built-in default
|
|
247
|
+
(``DEFENSIVE_CLOSE_RESOLUTION_GRACE_S``, currently 30 seconds).
|
|
248
|
+
Plugins on slow venues with multi-minute post-trade reporting
|
|
249
|
+
latency may override.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
on_unexpected_cancel: str = "stop"
|
|
253
|
+
"""
|
|
254
|
+
Policy for bot-owned orders that disappear without the bot cancelling them.
|
|
255
|
+
|
|
256
|
+
One of ``"stop"`` (quarantine: trading stops, the process stays
|
|
257
|
+
alive as a live observer — default), ``"stop_and_cancel"``
|
|
258
|
+
(quarantine + cancel remaining bot orders), ``"re_place"``
|
|
259
|
+
(auto-replace protective orders), ``"ignore"`` (continue), or
|
|
260
|
+
``"halt"`` (exit the process via the graceful manual-intervention
|
|
261
|
+
path). The class-level default is the quarantining fallback used by
|
|
262
|
+
test paths that construct plugins without the CLI. In production
|
|
263
|
+
the value comes from the cross-broker ``workdir/config/brokers.toml``
|
|
264
|
+
via :class:`~pynecore.core.broker.defaults.BrokerDefaults` — the
|
|
265
|
+
``pyne run --broker`` entry point loads it once and assigns it as
|
|
266
|
+
an instance attribute on the plugin before the script runner
|
|
267
|
+
starts. The policy is broker-agnostic by design, so plugins do
|
|
268
|
+
not declare it in their own ``Config`` dataclasses.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
_account_id: str | None = None
|
|
272
|
+
"""Plugin-qualified broker account identifier.
|
|
273
|
+
|
|
274
|
+
Subclasses populate this during the authentication flow
|
|
275
|
+
(``connect()`` / ``create_session()``), e.g.
|
|
276
|
+
``self._account_id = f"capitalcom-demo-{preferred_account}"``. The
|
|
277
|
+
:meth:`account_id` property reads from here and falls back to
|
|
278
|
+
``"default"`` when the plugin has not been authenticated — keeps tests
|
|
279
|
+
and backtest-only paths working without mandating an identity.
|
|
280
|
+
|
|
281
|
+
The value is used by the :class:`~pynecore.core.broker.run_identity.RunIdentity`
|
|
282
|
+
to derive the ``run_id`` and ``run_tag``. It is fixed at run-creation
|
|
283
|
+
time — if the user switches accounts at the broker UI mid-run, the bot
|
|
284
|
+
must be restarted so a new ``run_instance_id`` is allocated. That is
|
|
285
|
+
the intended safety boundary, not a limitation.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def account_id(self) -> str:
|
|
290
|
+
"""Plugin-qualified broker account identifier.
|
|
291
|
+
|
|
292
|
+
Sync property (not async) by design: the identity must be fixed
|
|
293
|
+
before the broker storage opens a run, and the storage layer is
|
|
294
|
+
sync. Authentication (which **is** network I/O) populates
|
|
295
|
+
``self._account_id`` once; the property reads it back without
|
|
296
|
+
making any further calls.
|
|
297
|
+
|
|
298
|
+
:return: The populated identifier, or ``"default"`` when the plugin
|
|
299
|
+
has not yet authenticated. Returning a sentinel rather than
|
|
300
|
+
raising keeps test paths (mock brokers, backtests) simple.
|
|
301
|
+
"""
|
|
302
|
+
return self._account_id or "default"
|
|
303
|
+
|
|
304
|
+
store_ctx: 'RunContext | None' = None
|
|
305
|
+
"""Optional per-run :class:`RunContext` from the unified broker storage.
|
|
306
|
+
|
|
307
|
+
The :class:`~pynecore.core.script_runner.ScriptRunner` sets this on the
|
|
308
|
+
plugin after :meth:`~pynecore.core.broker.storage.BrokerStore.open_run`
|
|
309
|
+
returns, so plugin code can perform ``add_ref`` / ``find_by_ref`` /
|
|
310
|
+
``upsert_order`` / ``log_event`` calls without receiving the context
|
|
311
|
+
as a parameter on every broker API. ``None`` during test paths or
|
|
312
|
+
when persistence is off; plugin methods should guard accordingly.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
native_failsafe_observed_sink: Callable[..., None] | None = None
|
|
316
|
+
"""Optional §2.6.7 broker-native fail-safe recovery feed.
|
|
317
|
+
|
|
318
|
+
The :class:`~pynecore.core.script_runner.ScriptRunner` installs a closure
|
|
319
|
+
that routes one broker-observed bracket triple into the Order Sync Engine's
|
|
320
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.record_native_bracket_observed`
|
|
321
|
+
(it supplies the engine's bar-clock ``now_ms``, so every fail-safe timestamp
|
|
322
|
+
stays on one clock). A plugin's reconcile pass calls this once per live
|
|
323
|
+
position so a parent stuck in ``DEGRADING`` (restart-replayed, or after a
|
|
324
|
+
PUT retry whose success report could not be confirmed directly) flips back
|
|
325
|
+
to ``HEALTHY`` once the broker is observed carrying the desired worst-SL.
|
|
326
|
+
Without the feed the stale-window timer escalates ``DEGRADING -> DEGRADED``,
|
|
327
|
+
blocking new entries / partial brackets until a manual ``reset_to_engine``.
|
|
328
|
+
|
|
329
|
+
The callee is keyed by ``parent_entry_dispatch_ref`` (the entry order's
|
|
330
|
+
``client_order_id``) and the engine no-ops for refs it does not track, so
|
|
331
|
+
the plugin may feed every live position blindly without consulting the
|
|
332
|
+
fail-safe state. ``None`` when persistence is off or the runner has not
|
|
333
|
+
wired it (state-only test paths); the plugin must guard accordingly.
|
|
334
|
+
|
|
335
|
+
Signature: ``sink(parent_entry_dispatch_ref, *, stop_level, profit_level,
|
|
336
|
+
trailing_stop)`` — pass ``None`` for fields the broker is not carrying.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
quarantine_sink: Callable[..., None] | None = None
|
|
340
|
+
"""Optional quarantine latch into the Order Sync Engine.
|
|
341
|
+
|
|
342
|
+
The :class:`~pynecore.core.script_runner.ScriptRunner` wires this to
|
|
343
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.record_quarantine`.
|
|
344
|
+
The plugin's disappearance tracking (typically a
|
|
345
|
+
:class:`~pynecore.core.broker.disappearance.DisappearanceTracker`
|
|
346
|
+
constructed with this as its ``request_quarantine`` hook) calls it when
|
|
347
|
+
the ``stop`` / ``stop_and_cancel`` policy fires: trading stops (the
|
|
348
|
+
engine blocks new entry dispatch and entry amends) while the process —
|
|
349
|
+
including the plugin's own event stream — keeps running, so the bot
|
|
350
|
+
stays a live observer of its open exposure instead of dying on a live
|
|
351
|
+
market. ``None`` when the runner has not wired it (state-only test
|
|
352
|
+
paths); the tracker then falls back to the process-exiting halt, which
|
|
353
|
+
is fail-safe, never fail-open.
|
|
354
|
+
|
|
355
|
+
Signature: ``sink(reason: str, context: dict | None = None)``.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
native_cancel_all_expected_sink: Callable[..., None] | None = None
|
|
359
|
+
"""Optional expected-cancel registration for a native bulk cancel.
|
|
360
|
+
|
|
361
|
+
The :class:`~pynecore.core.script_runner.ScriptRunner` wires this to
|
|
362
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine.enqueue_native_cancel_all_expected`.
|
|
363
|
+
A plugin that implements :meth:`execute_cancel_all` with a single native
|
|
364
|
+
bulk-cancel endpoint (e.g. Bybit ``POST /v5/order/cancel-all``) bypasses
|
|
365
|
+
the engine's per-order :class:`~pynecore.core.broker.models.CancelIntent`
|
|
366
|
+
dispatch, so the subsequent broker-pushed ``CANCELLED`` events for the
|
|
367
|
+
orders it removed would otherwise be misread as EXTERNAL cancels and trip
|
|
368
|
+
the :attr:`on_unexpected_cancel` quarantine. The plugin MUST call this
|
|
369
|
+
sink BEFORE issuing the venue bulk-cancel so the engine registers every
|
|
370
|
+
currently-mapped order id as expected-to-cancel; the marker is FIFO-ordered
|
|
371
|
+
ahead of the ``CANCELLED`` pushes it precedes. ``None`` when the runner has
|
|
372
|
+
not wired it (state-only test paths); the plugin must guard accordingly.
|
|
373
|
+
|
|
374
|
+
Signature: ``sink(symbol: str | None = None)``.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
on_inventory_conflict: str = "quarantine"
|
|
378
|
+
"""
|
|
379
|
+
Policy for a confirmed spot balance-invariant conflict.
|
|
380
|
+
|
|
381
|
+
Only meaningful for plugins that opt into the core spot inventory
|
|
382
|
+
layer (:attr:`spot_inventory_port`). ``"quarantine"`` (default)
|
|
383
|
+
latches the engine's quarantine — trading stops, the process stays
|
|
384
|
+
alive as an observer; ``"halt"`` exits via the graceful
|
|
385
|
+
manual-intervention path. Deliberately narrower than
|
|
386
|
+
:attr:`on_unexpected_cancel`: ``re_place`` would buy back an
|
|
387
|
+
operator's withdrawal and ``ignore`` would trade on corrupt books,
|
|
388
|
+
so neither applies to an attribution conflict. In production the
|
|
389
|
+
value comes from ``workdir/config/brokers.toml`` via
|
|
390
|
+
:class:`~pynecore.core.broker.defaults.BrokerDefaults`, injected
|
|
391
|
+
like :attr:`on_unexpected_cancel`.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
spot_inventory_port: 'SpotInventoryPort | None' = None
|
|
395
|
+
"""Optional spot-venue inventory surface.
|
|
396
|
+
|
|
397
|
+
A spot plugin (no venue position object; pooled balances) sets this
|
|
398
|
+
(typically to ``self``) so the core
|
|
399
|
+
:class:`~pynecore.core.broker.spot_inventory.SpotInventoryManager`
|
|
400
|
+
can own the execution ledger, the balance-invariant reconciliation
|
|
401
|
+
and the position synthesis. Stays ``None`` for margin/CFD venues.
|
|
402
|
+
The startup contract probe
|
|
403
|
+
(:func:`~pynecore.core.broker.validation.validate_plugin_contract`)
|
|
404
|
+
verifies the full port surface when set.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
position_port: 'PositionPort | None' = None
|
|
408
|
+
"""Optional one-way emulation transport (hedging-mode accounts).
|
|
409
|
+
|
|
410
|
+
When non-``None``, the Order Sync Engine routes reducing / closing and
|
|
411
|
+
reversing intents for this plugin through the core
|
|
412
|
+
:class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` instead of
|
|
413
|
+
the plugin's ``execute_close`` / ``execute_entry``, so the per-leg FIFO
|
|
414
|
+
fan-out and its persist-first crash/replay live in core. The plugin sets
|
|
415
|
+
this (typically to ``self``) once it knows the account is hedging-mode; it
|
|
416
|
+
stays ``None`` for netting accounts and for plugins that do not emulate,
|
|
417
|
+
leaving their existing dispatch path untouched.
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
# === High-level order intents ===
|
|
421
|
+
#
|
|
422
|
+
# Every execute_* method takes a :class:`DispatchEnvelope` rather than a
|
|
423
|
+
# bare intent. The envelope carries the idempotency metadata the plugin
|
|
424
|
+
# needs to allocate stable ``client_order_id`` values via
|
|
425
|
+
# :meth:`DispatchEnvelope.client_order_id`. The wrapped intent is on
|
|
426
|
+
# ``envelope.intent`` with its original Pine-level fields intact.
|
|
427
|
+
|
|
428
|
+
@abstractmethod
|
|
429
|
+
async def execute_entry(self, envelope: 'DispatchEnvelope') -> list['ExchangeOrder']:
|
|
430
|
+
"""
|
|
431
|
+
Open or add to a position.
|
|
432
|
+
|
|
433
|
+
Maps to ``strategy.entry()`` and ``strategy.order()``. ``envelope.intent``
|
|
434
|
+
is the :class:`EntryIntent`. Use ``envelope.client_order_id(KIND_ENTRY)``
|
|
435
|
+
for the exchange-side client id.
|
|
436
|
+
|
|
437
|
+
| Pine params | order_type | limit | stop |
|
|
438
|
+
|---------------------|--------------|----------|----------|
|
|
439
|
+
| no limit, no stop | MARKET | None | None |
|
|
440
|
+
| limit only | LIMIT | price | None |
|
|
441
|
+
| stop only | STOP | None | trigger |
|
|
442
|
+
|
|
443
|
+
A both-set Pine entry (``limit`` AND ``stop``) is not a stop-limit
|
|
444
|
+
order — Pine has none. The sync engine splits it into two OCO legs
|
|
445
|
+
before dispatch: the LIMIT leg arrives here as ``order_type=LIMIT``,
|
|
446
|
+
and if the stop side triggers the engine sends a separate
|
|
447
|
+
``order_type=MARKET`` entry. The plugin therefore never receives a
|
|
448
|
+
single both-set order — only plain MARKET / LIMIT / STOP.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
@abstractmethod
|
|
452
|
+
async def execute_exit(self, envelope: 'DispatchEnvelope') -> list['ExchangeOrder']:
|
|
453
|
+
"""
|
|
454
|
+
Exit (reduce) a position. OCA REDUCE semantics expected.
|
|
455
|
+
|
|
456
|
+
Maps to ``strategy.exit()``. ``envelope.intent`` is the
|
|
457
|
+
:class:`ExitIntent`. Allocate per-leg client ids via
|
|
458
|
+
``envelope.client_order_id(KIND_EXIT_TP)`` and
|
|
459
|
+
``envelope.client_order_id(KIND_EXIT_SL)``.
|
|
460
|
+
|
|
461
|
+
The plugin decides HOW to implement the TP+SL bracket on its exchange:
|
|
462
|
+
native bracket orders, separate orders with monitoring, etc.
|
|
463
|
+
|
|
464
|
+
MUST handle: partial fill on one leg adjusts the other. If the
|
|
465
|
+
exchange cannot support a required combination, raise
|
|
466
|
+
:class:`ExchangeCapabilityError`.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
@abstractmethod
|
|
470
|
+
async def execute_close(self, envelope: 'DispatchEnvelope') -> 'ExchangeOrder':
|
|
471
|
+
"""
|
|
472
|
+
Close a position with a market order.
|
|
473
|
+
|
|
474
|
+
Maps to ``strategy.close()`` / ``strategy.close_all()``. Use
|
|
475
|
+
``envelope.client_order_id(KIND_CLOSE)`` for the exchange-side id.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
@abstractmethod
|
|
479
|
+
async def execute_cancel(self, envelope: 'DispatchEnvelope') -> bool:
|
|
480
|
+
"""
|
|
481
|
+
Cancel pending order(s). Returns ``True`` if cancelled.
|
|
482
|
+
|
|
483
|
+
``envelope.intent`` is the :class:`CancelIntent`. The canonical cancel
|
|
484
|
+
id (``envelope.client_order_id(KIND_CANCEL)``) is primarily useful for
|
|
485
|
+
audit and retry correlation — the actual exchange call typically
|
|
486
|
+
references the existing order by its exchange-side id.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
async def execute_cancel_with_outcome(
|
|
490
|
+
self, envelope: 'DispatchEnvelope',
|
|
491
|
+
) -> CancelDispositionOutcome:
|
|
492
|
+
"""
|
|
493
|
+
Cancel pending order(s) and return a normalized disposition outcome.
|
|
494
|
+
|
|
495
|
+
Used by the sync engine's cancel-tentative state machine to drive
|
|
496
|
+
``reconcile()``'s idempotent cancel-retry loop forward without
|
|
497
|
+
making broker-specific assumptions (e.g., what HTTP 404 means).
|
|
498
|
+
Each plugin override classifies its exchange-side responses into
|
|
499
|
+
the five :class:`CancelDispositionOutcome` categories.
|
|
500
|
+
|
|
501
|
+
The default implementation maps the existing :meth:`execute_cancel`
|
|
502
|
+
contract conservatively to
|
|
503
|
+
:attr:`CancelDispositionOutcome.UNKNOWN`:
|
|
504
|
+
|
|
505
|
+
- ``execute_cancel`` returns ``True`` →
|
|
506
|
+
:attr:`CancelDispositionOutcome.UNKNOWN`
|
|
507
|
+
- ``execute_cancel`` returns ``False`` →
|
|
508
|
+
:attr:`CancelDispositionOutcome.UNKNOWN`
|
|
509
|
+
- ``execute_cancel`` raises
|
|
510
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError` →
|
|
511
|
+
:attr:`CancelDispositionOutcome.UNKNOWN`
|
|
512
|
+
- Any other exception propagates.
|
|
513
|
+
|
|
514
|
+
Both boolean outcomes collapse to ``UNKNOWN`` because the bool-only
|
|
515
|
+
:meth:`execute_cancel` contract cannot disambiguate the four terminal
|
|
516
|
+
cancel dispositions safely:
|
|
517
|
+
|
|
518
|
+
- ``True`` can mean a confirmed cancel OR a benign no-op (no live row
|
|
519
|
+
matched: the parent may have already filled, leaving an open
|
|
520
|
+
position the engine must keep tracking). Mapping ``True`` to
|
|
521
|
+
:attr:`CancelDispositionOutcome.CANCEL_CONFIRMED` would let the
|
|
522
|
+
sync engine abort the partial-bracket legs of a freshly filled
|
|
523
|
+
parent — exactly the opposite of the intended safety behaviour.
|
|
524
|
+
- ``False`` means *"cancel did not land"* (still pending), the
|
|
525
|
+
opposite of the ``STILL_OPEN`` enum semantic, which the sync
|
|
526
|
+
engine treats like a confirmed cancel (legs aborted, mapping
|
|
527
|
+
dropped).
|
|
528
|
+
|
|
529
|
+
``UNKNOWN`` keeps the cancel-tentative armed; the engine retries on
|
|
530
|
+
the next ``reconcile()`` (or resolves via a broker-pushed FILL /
|
|
531
|
+
CANCEL event in the meantime), so progress still happens.
|
|
532
|
+
|
|
533
|
+
``CANCEL_CONFIRMED``, ``ALREADY_FILLED``, ``TOO_LATE_TO_CANCEL`` and
|
|
534
|
+
``STILL_OPEN`` outcomes are reachable ONLY via plugin override — the
|
|
535
|
+
default cannot disambiguate them from the bool-only contract.
|
|
536
|
+
Plugins that can read the exchange's post-cancel disposition
|
|
537
|
+
(activity history, position diff) SHOULD override and emit the more
|
|
538
|
+
precise outcome.
|
|
539
|
+
|
|
540
|
+
:param envelope: Dispatch envelope around the
|
|
541
|
+
:class:`CancelIntent`; ``envelope.intent`` is the cancel
|
|
542
|
+
request, ``envelope.client_order_id(KIND_CANCEL)`` is the
|
|
543
|
+
audit-correlation id.
|
|
544
|
+
:return: Normalized cancel disposition outcome.
|
|
545
|
+
"""
|
|
546
|
+
try:
|
|
547
|
+
await self.execute_cancel(envelope)
|
|
548
|
+
except OrderDispositionUnknownError:
|
|
549
|
+
return CancelDispositionOutcome.UNKNOWN
|
|
550
|
+
return CancelDispositionOutcome.UNKNOWN
|
|
551
|
+
|
|
552
|
+
# noinspection PyMethodMayBeStatic,PyUnusedLocal
|
|
553
|
+
async def execute_cancel_all(self, symbol: str | None = None) -> int:
|
|
554
|
+
"""Cancel all open orders. Returns the count cancelled."""
|
|
555
|
+
raise ExchangeCapabilityError("Bulk cancel not supported")
|
|
556
|
+
|
|
557
|
+
# === Defensive-close recovery (bracket attach reject) ===
|
|
558
|
+
|
|
559
|
+
# noinspection PyMethodMayBeStatic,PyUnusedLocal
|
|
560
|
+
def get_residual_orders_after_bracket_attach_reject(
|
|
561
|
+
self, context: 'BracketAttachRejectContext',
|
|
562
|
+
) -> list[str]:
|
|
563
|
+
"""Enumerate broker-side orders the engine must cancel as part of
|
|
564
|
+
defensive recovery after a bracket attach reject.
|
|
565
|
+
|
|
566
|
+
Called by the engine's defensive-close path (see
|
|
567
|
+
:class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`
|
|
568
|
+
and the ``defensive-close-pending-lifecycle`` design dossier)
|
|
569
|
+
after the engine has dispatched the defensive close. The returned
|
|
570
|
+
broker-side refs are passed back one-by-one to
|
|
571
|
+
:meth:`cancel_broker_order_ref`.
|
|
572
|
+
|
|
573
|
+
Default implementation returns an empty list. Plugins that
|
|
574
|
+
raise :class:`BracketAttachAfterFillRejectedError` MUST override
|
|
575
|
+
this method when their execution model can leave residual
|
|
576
|
+
cancellable orders, namely:
|
|
577
|
+
|
|
578
|
+
- Residual unfilled portion of a partial-fill parent entry that
|
|
579
|
+
is NOT auto-cancelled by the exchange when the bracket attach
|
|
580
|
+
fails.
|
|
581
|
+
- Separate TP/SL order entities (when the exchange does not use
|
|
582
|
+
position-attached protective levels).
|
|
583
|
+
|
|
584
|
+
Implementations MUST be safe to call repeatedly with the same
|
|
585
|
+
context — startup replay invokes it again so terminal orders may
|
|
586
|
+
legitimately drop out of the list between calls.
|
|
587
|
+
|
|
588
|
+
:param context: Recovery context built from the raised
|
|
589
|
+
:class:`BracketAttachAfterFillRejectedError`.
|
|
590
|
+
:return: List of exchange-native order refs to cancel. Empty by
|
|
591
|
+
default; the engine treats an empty list as "no residual
|
|
592
|
+
cleanup required".
|
|
593
|
+
"""
|
|
594
|
+
return []
|
|
595
|
+
|
|
596
|
+
# noinspection PyMethodMayBeStatic,PyUnusedLocal
|
|
597
|
+
async def cancel_broker_order_ref(self, ref: str) -> None:
|
|
598
|
+
"""Cancel a broker-side order by its raw exchange ref.
|
|
599
|
+
|
|
600
|
+
Used by engine-internal recovery flows that operate on refs
|
|
601
|
+
outside the Pine intent system — currently the defensive-close
|
|
602
|
+
residual-cancel loop after a
|
|
603
|
+
:class:`~pynecore.core.broker.exceptions.BracketAttachAfterFillRejectedError`.
|
|
604
|
+
|
|
605
|
+
**Idempotency contract** (MUST be honoured by every plugin that
|
|
606
|
+
overrides this method):
|
|
607
|
+
|
|
608
|
+
- "not found" / "already cancelled" / "already filled" responses
|
|
609
|
+
from the exchange MUST be normalized to a successful no-op
|
|
610
|
+
return (NO exception).
|
|
611
|
+
- Transient connectivity failures MUST raise
|
|
612
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeConnectionError`
|
|
613
|
+
or
|
|
614
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
|
|
615
|
+
so the engine can retry on the next reconcile / restart.
|
|
616
|
+
- Any other exchange-side rejection (e.g.
|
|
617
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`)
|
|
618
|
+
MUST propagate — the engine treats it as an operator-attention
|
|
619
|
+
condition and halts.
|
|
620
|
+
|
|
621
|
+
The default implementation raises :class:`NotImplementedError`
|
|
622
|
+
— plugins that return non-empty lists from
|
|
623
|
+
:meth:`get_residual_orders_after_bracket_attach_reject` MUST
|
|
624
|
+
also override this method.
|
|
625
|
+
"""
|
|
626
|
+
raise NotImplementedError(
|
|
627
|
+
"cancel_broker_order_ref() must be overridden by plugins that "
|
|
628
|
+
"return non-empty lists from "
|
|
629
|
+
"get_residual_orders_after_bracket_attach_reject()"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# === Modify (upsert/replace) ===
|
|
633
|
+
|
|
634
|
+
async def modify_entry(
|
|
635
|
+
self, old: 'DispatchEnvelope', new: 'DispatchEnvelope',
|
|
636
|
+
) -> list['ExchangeOrder']:
|
|
637
|
+
"""
|
|
638
|
+
Modify an existing entry order (price/qty changed).
|
|
639
|
+
|
|
640
|
+
Default implementation: cancel + execute. Plugin authors SHOULD
|
|
641
|
+
override with an atomic amend when the exchange supports it.
|
|
642
|
+
"""
|
|
643
|
+
cancel_envelope = DispatchEnvelope(
|
|
644
|
+
intent=CancelIntent(
|
|
645
|
+
pine_id=old.intent.pine_id,
|
|
646
|
+
symbol=old.intent.symbol,
|
|
647
|
+
),
|
|
648
|
+
run_tag=new.run_tag,
|
|
649
|
+
bar_ts_ms=new.bar_ts_ms,
|
|
650
|
+
retry_seq=new.retry_seq,
|
|
651
|
+
coid_max_len=new.coid_max_len,
|
|
652
|
+
)
|
|
653
|
+
await self.execute_cancel(cancel_envelope)
|
|
654
|
+
return await self.execute_entry(new)
|
|
655
|
+
|
|
656
|
+
async def modify_exit(
|
|
657
|
+
self, old: 'DispatchEnvelope', new: 'DispatchEnvelope',
|
|
658
|
+
) -> list['ExchangeOrder']:
|
|
659
|
+
"""
|
|
660
|
+
Modify an existing exit bracket (TP/SL price changed).
|
|
661
|
+
|
|
662
|
+
Default: cancel + new. This opens a window without protection —
|
|
663
|
+
plugin authors SHOULD override with an atomic amend when the
|
|
664
|
+
exchange supports it (``editOrder``, Bybit amend, etc.).
|
|
665
|
+
"""
|
|
666
|
+
old_exit = old.intent
|
|
667
|
+
cancel_envelope = DispatchEnvelope(
|
|
668
|
+
intent=CancelIntent(
|
|
669
|
+
pine_id=old_exit.pine_id,
|
|
670
|
+
symbol=old_exit.symbol,
|
|
671
|
+
from_entry=getattr(old_exit, 'from_entry', None),
|
|
672
|
+
),
|
|
673
|
+
run_tag=new.run_tag,
|
|
674
|
+
bar_ts_ms=new.bar_ts_ms,
|
|
675
|
+
retry_seq=new.retry_seq,
|
|
676
|
+
coid_max_len=new.coid_max_len,
|
|
677
|
+
)
|
|
678
|
+
await self.execute_cancel(cancel_envelope)
|
|
679
|
+
return await self.execute_exit(new)
|
|
680
|
+
|
|
681
|
+
# === State queries ===
|
|
682
|
+
|
|
683
|
+
@abstractmethod
|
|
684
|
+
async def get_open_orders(self, symbol: str | None = None) -> list['ExchangeOrder']:
|
|
685
|
+
"""Fetch all open orders from the exchange."""
|
|
686
|
+
|
|
687
|
+
@abstractmethod
|
|
688
|
+
async def get_position(self, symbol: str) -> 'ExchangePosition | None':
|
|
689
|
+
"""Fetch the current position for ``symbol``.
|
|
690
|
+
|
|
691
|
+
``None`` means "no position exists for this symbol" and the engine
|
|
692
|
+
treats it as an **authoritative flat**: the startup ``reconcile``
|
|
693
|
+
adopts it unconditionally (a restart over ``None`` starts flat), and
|
|
694
|
+
the periodic ``reconcile`` reads a held-position → ``None``
|
|
695
|
+
transition as an external flatten. Returning ``None`` merely because
|
|
696
|
+
the venue has no native position *object* is therefore wrong — after
|
|
697
|
+
a restart the engine would forget the open exposure and double-open
|
|
698
|
+
on the first entry signal.
|
|
699
|
+
|
|
700
|
+
Spot venues MUST synthesize an :class:`ExchangePosition` from the
|
|
701
|
+
plugin's own persisted fill ledger (net base inventory as ``size``,
|
|
702
|
+
ledger VWAP as ``entry_price``, mark-to-market ``unrealized_pnl``,
|
|
703
|
+
``leverage=1.0``) and may return ``None`` only when the bot's net
|
|
704
|
+
inventory is genuinely flat.
|
|
705
|
+
"""
|
|
706
|
+
|
|
707
|
+
@abstractmethod
|
|
708
|
+
async def get_balance(self) -> dict[str, float]:
|
|
709
|
+
"""Get available balance per currency."""
|
|
710
|
+
|
|
711
|
+
# === Live order stream ===
|
|
712
|
+
|
|
713
|
+
def watch_orders(self) -> AsyncIterator['OrderEvent']:
|
|
714
|
+
"""
|
|
715
|
+
Stream order status updates.
|
|
716
|
+
|
|
717
|
+
If not implemented, the framework polls :meth:`get_open_orders` on
|
|
718
|
+
each bar. Return an async iterator of :class:`OrderEvent` objects;
|
|
719
|
+
the plugin is responsible for filling in the Pine identity fields
|
|
720
|
+
(``pine_id``, ``from_entry``, ``leg_type``) on each event.
|
|
721
|
+
|
|
722
|
+
The stream is the only channel through which the engine learns
|
|
723
|
+
about broker-side state transitions, and the plugin is free to
|
|
724
|
+
emit *synthesised* events alongside any native exchange feed:
|
|
725
|
+
|
|
726
|
+
- A ``cancelled`` event for a bot-owned order the plugin itself
|
|
727
|
+
observed disappearing (e.g. via an internal ``/positions`` +
|
|
728
|
+
``/workingorders`` poll past a grace window) — the engine's
|
|
729
|
+
event router pops the matching ``_order_mapping`` entry exactly
|
|
730
|
+
as it would for a native cancel.
|
|
731
|
+
- Recovery events that backfill state missed during a network
|
|
732
|
+
drop or restart, before the native stream catches up.
|
|
733
|
+
|
|
734
|
+
Plugins that decide a disappearance warrants a graceful halt
|
|
735
|
+
instead of a soft cancel should raise
|
|
736
|
+
:class:`~pynecore.core.broker.exceptions.UnexpectedCancelError`
|
|
737
|
+
from the same generator — the engine catches it on the broker
|
|
738
|
+
thread and latches a halt flag so the next runner tick exits via
|
|
739
|
+
its ``finally`` block.
|
|
740
|
+
"""
|
|
741
|
+
raise NotImplementedError
|
|
742
|
+
|
|
743
|
+
# === Exception mapping ===
|
|
744
|
+
|
|
745
|
+
# noinspection PyMethodMayBeStatic
|
|
746
|
+
def _map_exception(self, raw: Exception) -> BrokerError | None:
|
|
747
|
+
"""Translate a raw exchange-SDK exception into the broker taxonomy.
|
|
748
|
+
|
|
749
|
+
Utility hook for plugin authors — **not** called by the sync engine
|
|
750
|
+
directly. Plugin ``execute_*`` implementations wrap their SDK calls in
|
|
751
|
+
try/except and delegate classification here so exchange-specific
|
|
752
|
+
knowledge stays in one place. Default implementation only handles
|
|
753
|
+
stdlib exceptions common to every plugin: a concrete plugin should
|
|
754
|
+
override to layer in its SDK's error types (``ccxt.AuthenticationError``,
|
|
755
|
+
IB ``errorCode``, etc.) and return ``None`` for anything it doesn't
|
|
756
|
+
recognise so the caller re-raises the original.
|
|
757
|
+
|
|
758
|
+
:returns: A :class:`BrokerError` subclass instance if ``raw`` can be
|
|
759
|
+
classified, or ``None`` if the plugin should re-raise as-is.
|
|
760
|
+
"""
|
|
761
|
+
if isinstance(raw, ConnectionError):
|
|
762
|
+
return ExchangeConnectionError(str(raw) or "Connection lost")
|
|
763
|
+
return None
|
|
764
|
+
|
|
765
|
+
# === Capabilities ===
|
|
766
|
+
|
|
767
|
+
@abstractmethod
|
|
768
|
+
def get_capabilities(self) -> 'ExchangeCapabilities':
|
|
769
|
+
"""
|
|
770
|
+
Declare what the exchange supports.
|
|
771
|
+
|
|
772
|
+
Called once at startup for validation against script requirements
|
|
773
|
+
(see :func:`~pynecore.core.broker.validation.validate_at_startup`).
|
|
774
|
+
|
|
775
|
+
Each field is a :class:`~pynecore.core.broker.models.CapabilityLevel`:
|
|
776
|
+
``UNSUPPORTED`` (rejects scripts that need it),
|
|
777
|
+
``SOFTWARE`` / ``PARTIAL_NATIVE`` / ``NATIVE`` (all pass validation,
|
|
778
|
+
the level is a diagnostic). The sync engine reads ``NATIVE`` as
|
|
779
|
+
"exchange is authoritative; suppress my fallback" for ``oca_cancel``
|
|
780
|
+
and ``tp_sl_bracket``.
|
|
781
|
+
"""
|