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,1785 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crash-safe dispatch journal for the broker-runtime layer.
|
|
3
|
+
|
|
4
|
+
Broker plugins currently encode their own persist-first state machine
|
|
5
|
+
for every ``execute_*`` call: write the order row, log the audit
|
|
6
|
+
event, POST to the exchange, parse the response, mirror the server
|
|
7
|
+
reference, confirm-readback, finalize the row. The Capital.com plugin
|
|
8
|
+
has six methods that re-implement the same nine-step pattern with
|
|
9
|
+
exchange-specific endpoints, response shapes, and reject codes
|
|
10
|
+
sprinkled between them.
|
|
11
|
+
|
|
12
|
+
:class:`DispatchJournal` owns the persist-first state machine. The
|
|
13
|
+
plugin provides typed hooks for the parts that genuinely differ
|
|
14
|
+
between exchanges: request shape, response parsing, reject
|
|
15
|
+
classification. The journal coordinates writes against the
|
|
16
|
+
:class:`~pynecore.core.broker.storage.RunContext` through the typed
|
|
17
|
+
helpers in :mod:`pynecore.core.broker.store_helpers`, so the canonical
|
|
18
|
+
``extras`` schema and the order-of-writes invariants live in exactly
|
|
19
|
+
one place.
|
|
20
|
+
|
|
21
|
+
This module is the M1 proof-of-shape — entry dispatch only, no
|
|
22
|
+
brackets, no modify. It is shipped *alongside* the existing plugin
|
|
23
|
+
state machine so the parity test can compare both paths byte-for-byte
|
|
24
|
+
before any production code path is rewritten. The replacement plan is
|
|
25
|
+
documented in ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``.
|
|
26
|
+
"""
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from pynecore.core.broker.exceptions import (
|
|
32
|
+
ExchangeOrderRejectedError,
|
|
33
|
+
OrderDispositionUnknownError,
|
|
34
|
+
)
|
|
35
|
+
from pynecore.core.broker.models import (
|
|
36
|
+
CancelIntent,
|
|
37
|
+
CloseIntent,
|
|
38
|
+
EntryIntent,
|
|
39
|
+
ExchangeOrder,
|
|
40
|
+
ExitIntent,
|
|
41
|
+
)
|
|
42
|
+
from pynecore.core.broker.store_helpers import (
|
|
43
|
+
KIND_CANCEL,
|
|
44
|
+
KIND_FULL_CLOSE,
|
|
45
|
+
KIND_MODIFY_ENTRY,
|
|
46
|
+
KIND_MODIFY_EXIT,
|
|
47
|
+
KIND_PARTIAL_CLOSE,
|
|
48
|
+
create_cancel_command_row,
|
|
49
|
+
create_close_target_row,
|
|
50
|
+
create_entry_order_row,
|
|
51
|
+
create_modify_entry_row,
|
|
52
|
+
create_modify_exit_row,
|
|
53
|
+
find_pending_dispatch,
|
|
54
|
+
mark_cancel_completed,
|
|
55
|
+
mark_close_completed,
|
|
56
|
+
mark_closing,
|
|
57
|
+
mark_confirmed_with_fill,
|
|
58
|
+
mark_disposition_unknown,
|
|
59
|
+
mark_modify_completed,
|
|
60
|
+
mark_reconcile_filled,
|
|
61
|
+
mark_reconcile_terminal_close,
|
|
62
|
+
mark_rejected,
|
|
63
|
+
record_close_server_ref,
|
|
64
|
+
record_server_ref,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from pynecore.core.broker.storage import OrderRow, RunContext
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
'DispatchJournal',
|
|
72
|
+
'EntryDispatchHooks',
|
|
73
|
+
'CloseDispatchHooks',
|
|
74
|
+
'CancelDispatchHooks',
|
|
75
|
+
'ModifyEntryDispatchHooks',
|
|
76
|
+
'ModifyExitDispatchHooks',
|
|
77
|
+
'SubmitOutcome',
|
|
78
|
+
'ConfirmOutcome',
|
|
79
|
+
'ResumeOutcome',
|
|
80
|
+
'ResumeStatus',
|
|
81
|
+
'CloseOutcome',
|
|
82
|
+
'CancelOutcome',
|
|
83
|
+
'CancelReasonPath',
|
|
84
|
+
'ModifyEntryOutcome',
|
|
85
|
+
'ModifyExitOutcome',
|
|
86
|
+
'ModifyExitStatus',
|
|
87
|
+
'ReconcileOutcome',
|
|
88
|
+
'ReconcileKind',
|
|
89
|
+
'ReconcileReason',
|
|
90
|
+
'ReconcileTerminalState',
|
|
91
|
+
'PendingResolution',
|
|
92
|
+
'PendingHooksProvider',
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# === Hook return types =====================================================
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class SubmitOutcome:
|
|
100
|
+
"""Plugin's successful ``submit()`` result.
|
|
101
|
+
|
|
102
|
+
On any failure mode (timeout, missing reference, synchronous
|
|
103
|
+
reject) the hook raises an appropriate
|
|
104
|
+
:class:`~pynecore.core.broker.exceptions.BrokerError` subclass
|
|
105
|
+
instead of returning. The journal converts those raises to the
|
|
106
|
+
matching persisted state.
|
|
107
|
+
|
|
108
|
+
:ivar server_ref: Exchange-allocated reference for the submission
|
|
109
|
+
(``dealReference`` for Capital.com, ``orderLinkId`` echo for
|
|
110
|
+
Bybit, etc.). Persisted into ``order_refs`` under
|
|
111
|
+
``ref_type='deal_reference'``.
|
|
112
|
+
:ivar raw: Verbatim exchange response, attached to the
|
|
113
|
+
``deal_reference_seen`` audit event for forensics. ``None``
|
|
114
|
+
when the plugin does not want to expose the raw body.
|
|
115
|
+
"""
|
|
116
|
+
server_ref: str
|
|
117
|
+
raw: dict | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ConfirmOutcome:
|
|
122
|
+
"""Plugin's successful ``confirm_submission()`` result.
|
|
123
|
+
|
|
124
|
+
On a confirm-REJECTED outcome the hook raises
|
|
125
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
|
|
126
|
+
(or a subclass) so the journal can persist the rejection. On a
|
|
127
|
+
confirm-timeout it raises
|
|
128
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`.
|
|
129
|
+
|
|
130
|
+
:ivar exchange_id: Exchange-allocated id for the resulting order
|
|
131
|
+
/ position (``dealId``, ``orderId``, ...). ``None`` when the
|
|
132
|
+
exchange returns no id at confirm time — the journal still
|
|
133
|
+
advances state but skips the ``deal_id`` ref.
|
|
134
|
+
:ivar is_filled: ``True`` only for MARKET-side fills that confirm
|
|
135
|
+
as OPEN. LIMIT / STOP submissions confirm as ACCEPTED but not
|
|
136
|
+
filled; their fills arrive later via the activity stream.
|
|
137
|
+
:ivar filled_qty: Confirmed fill quantity. Ignored unless
|
|
138
|
+
``is_filled`` is ``True``.
|
|
139
|
+
:ivar fill_price: Confirm-side fill price. Persisted into
|
|
140
|
+
``extras['confirm_level']`` only when strictly positive (a
|
|
141
|
+
zero/negative level is a no-quote artefact and would corrupt
|
|
142
|
+
the recovery fallback).
|
|
143
|
+
:ivar raw: Verbatim confirm response, attached to the
|
|
144
|
+
``confirmed`` audit event for forensics.
|
|
145
|
+
"""
|
|
146
|
+
exchange_id: str | None
|
|
147
|
+
is_filled: bool
|
|
148
|
+
filled_qty: float = 0.0
|
|
149
|
+
fill_price: float | None = None
|
|
150
|
+
raw: dict | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
CancelReasonPath = Literal['deleted', 'already_gone', 'noop', 'recovered']
|
|
154
|
+
ModifyExitStatus = Literal['ACCEPTED', 'REJECTED']
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class CloseOutcome:
|
|
159
|
+
"""Plugin's successful close-dispatch result.
|
|
160
|
+
|
|
161
|
+
Returned from the plugin's ``submit_full_close`` or
|
|
162
|
+
``submit_partial_close`` hook. On a confirm-REJECTED outcome the
|
|
163
|
+
hook raises
|
|
164
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`;
|
|
165
|
+
on a network / disposition-unknown outcome it raises
|
|
166
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`.
|
|
167
|
+
|
|
168
|
+
:ivar mode: ``'full'`` for a full-close DELETE chain, ``'partial'``
|
|
169
|
+
for the partial-close emulated POST. The journal also receives
|
|
170
|
+
the ``kind`` argument up-front; the field is echoed here so a
|
|
171
|
+
single outcome shape covers both branches.
|
|
172
|
+
:ivar applied_targets: Exchange ``dealId`` strings the dispatch
|
|
173
|
+
actually touched. Full close: every target the DELETE chain
|
|
174
|
+
completed against. Partial close: a single-element list with
|
|
175
|
+
the newly-opened opposite leg's ``dealId``, or empty if the
|
|
176
|
+
POST returned no id.
|
|
177
|
+
:ivar deal_reference: Server-allocated POST reference. Only the
|
|
178
|
+
partial-close branch carries one; full-close returns ``None``.
|
|
179
|
+
:ivar exchange_id: Single representative exchange id for the
|
|
180
|
+
:class:`ExchangeOrder` the engine receives. For full close
|
|
181
|
+
this is the first target's ``dealId``; for partial close this
|
|
182
|
+
is the new opposite leg's ``dealId``.
|
|
183
|
+
:ivar filled_qty: Quantity reported as filled by the broker
|
|
184
|
+
response (or synthesised from the intent for the full-close
|
|
185
|
+
synchronous flow).
|
|
186
|
+
:ivar fill_price: Confirm-side price when known. ``None`` when
|
|
187
|
+
the broker did not echo a fill price.
|
|
188
|
+
:ivar raw: Verbatim broker response for forensics.
|
|
189
|
+
"""
|
|
190
|
+
mode: Literal['full', 'partial']
|
|
191
|
+
applied_targets: list[str]
|
|
192
|
+
deal_reference: str | None = None
|
|
193
|
+
exchange_id: str | None = None
|
|
194
|
+
filled_qty: float = 0.0
|
|
195
|
+
fill_price: float | None = None
|
|
196
|
+
raw: dict | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class CancelOutcome:
|
|
201
|
+
"""Plugin's successful cancel-dispatch result.
|
|
202
|
+
|
|
203
|
+
:ivar succeeded: ``True`` once the per-target sweep finished
|
|
204
|
+
(including the benign already-gone path). ``False`` is
|
|
205
|
+
currently unused — cancel failures raise rather than return.
|
|
206
|
+
:ivar reason_path: Why the cancel resolved this way:
|
|
207
|
+
|
|
208
|
+
- ``'deleted'`` — at least one target was actively swept by
|
|
209
|
+
the dispatch.
|
|
210
|
+
- ``'already_gone'`` — every target had already vanished from
|
|
211
|
+
the broker; nothing was DELETEd.
|
|
212
|
+
- ``'noop'`` — no targets matched the intent at all.
|
|
213
|
+
- ``'recovered'`` — recovery declared the cancel landed because
|
|
214
|
+
all targets vanished from the snapshots; never emitted by a
|
|
215
|
+
live dispatch, only by :meth:`DispatchJournal._apply_resume_outcome`.
|
|
216
|
+
|
|
217
|
+
:ivar cleared_legs: Number of bracket / working-order legs the
|
|
218
|
+
dispatch swept (``len(applied_target_coids)``).
|
|
219
|
+
:ivar applied_target_coids: Plugin-side COIDs the dispatch closed.
|
|
220
|
+
Persisted into ``extras['applied_target_coids']`` so recovery
|
|
221
|
+
can reason about which targets the per-target loop actually
|
|
222
|
+
reached before any crash.
|
|
223
|
+
:ivar raw: Verbatim broker response (or aggregated responses) for
|
|
224
|
+
forensics.
|
|
225
|
+
"""
|
|
226
|
+
succeeded: bool
|
|
227
|
+
reason_path: CancelReasonPath
|
|
228
|
+
cleared_legs: int
|
|
229
|
+
applied_target_coids: list[str]
|
|
230
|
+
raw: dict | None = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class ModifyEntryOutcome:
|
|
235
|
+
"""Plugin's successful working-order amend result.
|
|
236
|
+
|
|
237
|
+
:ivar server_ref: ``dealReference`` of the amend PUT. Persisted
|
|
238
|
+
under ``order_refs['deal_reference']`` so recovery can verify
|
|
239
|
+
the change landed via a confirm GET.
|
|
240
|
+
:ivar new_level: Echoed back from the confirm response — the
|
|
241
|
+
broker's view of the amended level. Compared against the
|
|
242
|
+
intent's requested level on recovery to detect drift.
|
|
243
|
+
:ivar raw: Verbatim confirm response for forensics.
|
|
244
|
+
"""
|
|
245
|
+
server_ref: str
|
|
246
|
+
new_level: float
|
|
247
|
+
raw: dict | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class ModifyExitOutcome:
|
|
252
|
+
"""Plugin's successful position bracket amend result.
|
|
253
|
+
|
|
254
|
+
:ivar server_ref: ``dealReference`` of the amend PUT.
|
|
255
|
+
:ivar deal_status: ``'ACCEPTED'`` for happy path,
|
|
256
|
+
``'REJECTED'`` is converted to
|
|
257
|
+
:class:`ExchangeOrderRejectedError` at the hook boundary so
|
|
258
|
+
the journal can persist the rejection; the field exists so
|
|
259
|
+
forensics see the broker's exact verdict.
|
|
260
|
+
:ivar rejected_reason: Free-form reject reason copied from the
|
|
261
|
+
confirm response when ``deal_status == 'REJECTED'``.
|
|
262
|
+
:ivar post_put_state: Mapping of the broker-echoed levels the
|
|
263
|
+
plugin's ``mirror_bracket_legs`` callback needs to materialise
|
|
264
|
+
the synthetic leg rows post-success. Keys are plugin-defined
|
|
265
|
+
(e.g. ``'profit_level'``, ``'stop_level'``,
|
|
266
|
+
``'trailing_stop'``).
|
|
267
|
+
:ivar raw: Verbatim confirm response for forensics.
|
|
268
|
+
"""
|
|
269
|
+
server_ref: str
|
|
270
|
+
deal_status: ModifyExitStatus
|
|
271
|
+
rejected_reason: str | None = None
|
|
272
|
+
post_put_state: Mapping[str, Any] = field(default_factory=dict)
|
|
273
|
+
raw: dict | None = None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
ResumeStatus = Literal['confirmed', 'rejected', 'still_unknown']
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class ResumeOutcome:
|
|
281
|
+
"""Plugin's verdict on a pending dispatch found at restart.
|
|
282
|
+
|
|
283
|
+
:ivar status: ``'confirmed'`` when the plugin verified the
|
|
284
|
+
submission landed (exchange shows the order / position).
|
|
285
|
+
``'rejected'`` when the plugin verified it did not land
|
|
286
|
+
(activity stream, snapshot, or confirm GET says so).
|
|
287
|
+
``'still_unknown'`` when the plugin cannot decide yet — the
|
|
288
|
+
journal leaves the row as-is and the engine's pending-
|
|
289
|
+
verification reconciler tries again on the next sync.
|
|
290
|
+
:ivar exchange_id: Same semantics as :class:`ConfirmOutcome`.
|
|
291
|
+
:ivar is_filled: Same semantics as :class:`ConfirmOutcome`.
|
|
292
|
+
:ivar filled_qty: Same semantics as :class:`ConfirmOutcome`.
|
|
293
|
+
:ivar fill_price: Same semantics as :class:`ConfirmOutcome`.
|
|
294
|
+
:ivar reject_reason: Free-form reject reason, written to the audit
|
|
295
|
+
event when ``status == 'rejected'``.
|
|
296
|
+
:ivar recovery_path: Plugin-defined category for the resolution
|
|
297
|
+
route — e.g. ``'stored_ref'``, ``'activity_single_match'``,
|
|
298
|
+
``'ttl_fallback_snapshot'``, ``'confirm_get_direct'``. Merged
|
|
299
|
+
into the ``recovered_*`` / ``recovery_pending`` audit event
|
|
300
|
+
payload when set. The Core does not validate the value; the
|
|
301
|
+
plugin owns the taxonomy.
|
|
302
|
+
:ivar recovery_context: Structured plugin diagnostic that goes
|
|
303
|
+
alongside ``recovery_path`` in the audit event (e.g.
|
|
304
|
+
``{'matched_snapshot': 'working', 'activity_count': 1}``).
|
|
305
|
+
"""
|
|
306
|
+
status: ResumeStatus
|
|
307
|
+
exchange_id: str | None = None
|
|
308
|
+
is_filled: bool = False
|
|
309
|
+
filled_qty: float = 0.0
|
|
310
|
+
fill_price: float | None = None
|
|
311
|
+
reject_reason: str | None = None
|
|
312
|
+
recovery_path: str | None = None
|
|
313
|
+
recovery_context: Mapping[str, Any] | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# === Reconcile-path outcome ================================================
|
|
317
|
+
|
|
318
|
+
ReconcileKind = Literal['filled', 'terminal_close']
|
|
319
|
+
|
|
320
|
+
# Validated reason tags for :class:`ReconcileOutcome`. New reasons require
|
|
321
|
+
# extending this literal; the journal does not enforce membership at
|
|
322
|
+
# runtime, but downstream tests and broker-plugin reviews check against
|
|
323
|
+
# this taxonomy. See ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``
|
|
324
|
+
# §4.2 for the full contract.
|
|
325
|
+
ReconcileReason = Literal[
|
|
326
|
+
# kind='filled'
|
|
327
|
+
'working_promoted_position',
|
|
328
|
+
'partial_fill_progress',
|
|
329
|
+
# kind='terminal_close'
|
|
330
|
+
'bracket_sibling_retired_on_mixed_rejection',
|
|
331
|
+
'pending_trail_parent_rejected',
|
|
332
|
+
'missing_pending_grace_expired',
|
|
333
|
+
'unexpected_cancel_cascade',
|
|
334
|
+
'bracket_natural_close_followup',
|
|
335
|
+
# kind='terminal_close' — startup in-flight recovery (persist-first crash
|
|
336
|
+
# recovery): a pending dispatch row read terminal from the order history,
|
|
337
|
+
# or a still-unknown row retired after the evidence-gated TTL.
|
|
338
|
+
'recovered_in_flight_terminal',
|
|
339
|
+
'recovered_abandoned_unknown',
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
# Terminal states the reconcile path may land a row in. ``'confirmed'`` is
|
|
343
|
+
# used by the working→position promotion (``kind='filled'``);
|
|
344
|
+
# ``'rejected'`` is the typical destination for bracket sibling retires and
|
|
345
|
+
# the cascade paths; ``'closed'`` is reserved for paths that already pass
|
|
346
|
+
# through a non-terminal state and merely need :meth:`RunContext.close_order`
|
|
347
|
+
# (current call sites use ``state='rejected'`` plus ``close_row=True``, but
|
|
348
|
+
# the literal is here for the bracket-natural-close follow-up reason).
|
|
349
|
+
ReconcileTerminalState = Literal['confirmed', 'rejected', 'closed']
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True)
|
|
353
|
+
class ReconcileOutcome:
|
|
354
|
+
"""Plugin verdict for one reconcile-path terminal mutation.
|
|
355
|
+
|
|
356
|
+
Emitted per-row by the plugin reconciler (``_reconcile_snapshot``,
|
|
357
|
+
``_missing_pending_tracker``, ``_maybe_raise_unexpected_cancel``) when
|
|
358
|
+
an observation, grace-window expiry, or cascade rule requires the
|
|
359
|
+
journal to persist a terminal lifecycle change. The journal owns the
|
|
360
|
+
actual ``state`` / ``filled_qty`` / ``closed_ts_ms`` / audit-event
|
|
361
|
+
writes; the plugin only declares "what happened and why".
|
|
362
|
+
|
|
363
|
+
The Cat 1 reconciler-private observation breadcrumbs
|
|
364
|
+
(``missing_pending_since``, ``close_event_yielded_at``,
|
|
365
|
+
``close_event_yielded_at_poll_id``) are NOT routed through this
|
|
366
|
+
outcome — they stay plugin-direct writes. Journal ownership is
|
|
367
|
+
limited to ``state`` / ``filled_qty`` / ``closed_ts_ms`` / terminal
|
|
368
|
+
timestamps / lifecycle audit events; everything else under
|
|
369
|
+
``extras`` is the plugin's namespace.
|
|
370
|
+
|
|
371
|
+
:ivar kind: ``'filled'`` for the working→position fill detection
|
|
372
|
+
(a row in :data:`~pynecore.core.broker.store_helpers.STATE_SERVER_REF_SEEN`
|
|
373
|
+
observed in ``/positions``). ``'terminal_close'`` for any other
|
|
374
|
+
reconcile-path retirement — bracket sibling retire, grace-window
|
|
375
|
+
expiry, unexpected-cancel cascade, eager-teardown follow-up.
|
|
376
|
+
:ivar reason: Validated literal tag describing the trigger; see
|
|
377
|
+
:data:`ReconcileReason`.
|
|
378
|
+
:ivar new_state: The state the row should land in. ``'confirmed'``
|
|
379
|
+
for ``kind='filled'``; ``'rejected'`` / ``'closed'`` for
|
|
380
|
+
``kind='terminal_close'``.
|
|
381
|
+
:ivar filled_qty: Fill quantity (mandatory when ``kind='filled'``;
|
|
382
|
+
ignored when ``kind='terminal_close'``).
|
|
383
|
+
:ivar extras_patch: Plugin-supplied extras to merge into the row in
|
|
384
|
+
the same transaction as the state mutation. Examples:
|
|
385
|
+
``{'kind': 'position', 'entry_filled_at': now_ts}`` for a
|
|
386
|
+
working→position fill, ``None`` for bracket retire. The journal
|
|
387
|
+
does not validate the keys.
|
|
388
|
+
:ivar close_row: ``True`` => journal also calls
|
|
389
|
+
:meth:`RunContext.close_order` to retire the row from
|
|
390
|
+
``iter_live_orders``. ``False`` for working→position (the row
|
|
391
|
+
stays live as a position); ``True`` for the bracket / cascade /
|
|
392
|
+
grace-expiry paths.
|
|
393
|
+
:ivar audit_event: The :meth:`RunContext.log_event` ``kind`` for the
|
|
394
|
+
audit row the journal writes after the state mutation. Plugin
|
|
395
|
+
chooses the name (the broker tests pin specific event names).
|
|
396
|
+
:ivar audit_payload: Mapping merged into the audit event payload.
|
|
397
|
+
:ivar exchange_order_id: Optional exchange-side id stamped into the
|
|
398
|
+
audit event (e.g. ``parent_deal_id`` for a bracket sibling
|
|
399
|
+
retire).
|
|
400
|
+
"""
|
|
401
|
+
kind: ReconcileKind
|
|
402
|
+
reason: ReconcileReason
|
|
403
|
+
new_state: ReconcileTerminalState
|
|
404
|
+
audit_event: str
|
|
405
|
+
filled_qty: float | None = None
|
|
406
|
+
extras_patch: Mapping[str, Any] | None = None
|
|
407
|
+
close_row: bool = False
|
|
408
|
+
audit_payload: Mapping[str, Any] | None = None
|
|
409
|
+
exchange_order_id: str | None = None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# === Hook protocol =========================================================
|
|
413
|
+
|
|
414
|
+
class EntryDispatchHooks(Protocol):
|
|
415
|
+
"""Plugin-supplied callbacks for an entry dispatch.
|
|
416
|
+
|
|
417
|
+
A fresh hook instance is constructed by the plugin per dispatch.
|
|
418
|
+
The journal owns persistence and state transitions; hooks own the
|
|
419
|
+
exchange wire format, response parsing, and rejection
|
|
420
|
+
classification.
|
|
421
|
+
|
|
422
|
+
All four methods are mandatory. The ``async`` methods may raise
|
|
423
|
+
any :class:`~pynecore.core.broker.exceptions.BrokerError`
|
|
424
|
+
subclass; the journal catches them and persists the matching
|
|
425
|
+
state before re-raising.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
async def submit(
|
|
429
|
+
self, *, coid: str, intent: EntryIntent, qty: float,
|
|
430
|
+
) -> SubmitOutcome:
|
|
431
|
+
"""Submit the order to the exchange.
|
|
432
|
+
|
|
433
|
+
Implementations issue exactly one REST / WS call and return a
|
|
434
|
+
:class:`SubmitOutcome` on success, OR raise
|
|
435
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
|
|
436
|
+
on network timeout / missing server reference, OR raise
|
|
437
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
|
|
438
|
+
when the POST itself produces a definitive synchronous reject
|
|
439
|
+
(e.g. a 4xx with a parseable reason and no server reference to
|
|
440
|
+
confirm against). The journal converts both raises into the
|
|
441
|
+
matching terminal / pending state. Implementations MUST NOT
|
|
442
|
+
write any persistence — the journal owns that.
|
|
443
|
+
|
|
444
|
+
The preferred pattern remains "defer rejection to
|
|
445
|
+
:meth:`confirm_submission`" because most exchanges do issue a
|
|
446
|
+
server reference even for soon-to-be-rejected orders, and the
|
|
447
|
+
confirm phase carries richer reason data. Raise
|
|
448
|
+
:class:`ExchangeOrderRejectedError` here only when the POST
|
|
449
|
+
response itself is the final word.
|
|
450
|
+
"""
|
|
451
|
+
...
|
|
452
|
+
|
|
453
|
+
async def confirm_submission(
|
|
454
|
+
self, *, coid: str, intent: EntryIntent, server_ref: str,
|
|
455
|
+
) -> ConfirmOutcome:
|
|
456
|
+
"""Read back the confirmation for a recorded server reference.
|
|
457
|
+
|
|
458
|
+
Implementations issue exactly one REST / WS call and return a
|
|
459
|
+
:class:`ConfirmOutcome` on success, OR raise
|
|
460
|
+
:class:`~pynecore.core.broker.exceptions.ExchangeOrderRejectedError`
|
|
461
|
+
on synchronous reject, OR
|
|
462
|
+
:class:`~pynecore.core.broker.exceptions.OrderDispositionUnknownError`
|
|
463
|
+
on confirm-timeout / unparseable response. They MUST NOT
|
|
464
|
+
write any persistence — the journal owns that.
|
|
465
|
+
|
|
466
|
+
For exchanges that have no separate confirm step (server
|
|
467
|
+
echoes the full result on POST), implement this as a pure
|
|
468
|
+
function that synthesises a :class:`ConfirmOutcome` from data
|
|
469
|
+
the plugin cached during ``submit``.
|
|
470
|
+
"""
|
|
471
|
+
...
|
|
472
|
+
|
|
473
|
+
def exchange_order_from_state(
|
|
474
|
+
self, *, row: 'OrderRow', intent: EntryIntent,
|
|
475
|
+
) -> ExchangeOrder:
|
|
476
|
+
"""Build the :class:`ExchangeOrder` the engine expects.
|
|
477
|
+
|
|
478
|
+
The journal calls this *after* :class:`ConfirmOutcome` has
|
|
479
|
+
been persisted, so the row carries the final fill state.
|
|
480
|
+
Pure function — no I/O, no persistence writes.
|
|
481
|
+
"""
|
|
482
|
+
...
|
|
483
|
+
|
|
484
|
+
async def resume_pending_dispatch(
|
|
485
|
+
self, *, row: 'OrderRow', refs: Mapping[str, str],
|
|
486
|
+
) -> ResumeOutcome:
|
|
487
|
+
"""Decide the disposition of a pre-restart pending row.
|
|
488
|
+
|
|
489
|
+
Called once per ``find_pending_dispatch`` hit during
|
|
490
|
+
:meth:`DispatchJournal.recover_pending`. The plugin checks
|
|
491
|
+
the exchange's authoritative view (activity stream, snapshot,
|
|
492
|
+
confirm GET) and returns the corresponding
|
|
493
|
+
:class:`ResumeOutcome`.
|
|
494
|
+
|
|
495
|
+
:param row: The persisted row, including its ``extras`` dict.
|
|
496
|
+
:param refs: Mapping of ``ref_type`` → ``ref_value`` already
|
|
497
|
+
recorded for this COID (``'deal_reference'`` is the
|
|
498
|
+
typical entry; ``'deal_id'`` may also be present when the
|
|
499
|
+
crash happened after server-ref-seen but before
|
|
500
|
+
confirmed).
|
|
501
|
+
"""
|
|
502
|
+
...
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class CloseDispatchHooks(Protocol):
|
|
506
|
+
"""Plugin-supplied callbacks for a close dispatch.
|
|
507
|
+
|
|
508
|
+
Exactly one of :meth:`submit_full_close` and
|
|
509
|
+
:meth:`submit_partial_close` is invoked per dispatch, decided by
|
|
510
|
+
the ``kind`` argument the journal receives. The other method is
|
|
511
|
+
not required to do anything meaningful — implementations typically
|
|
512
|
+
raise ``RuntimeError`` from the unused one as a defensive marker.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
async def submit_full_close(
|
|
516
|
+
self, *, coid: str, intent: CloseIntent,
|
|
517
|
+
targets: list['OrderRow'],
|
|
518
|
+
) -> CloseOutcome:
|
|
519
|
+
"""DELETE every target position. Synchronous fill.
|
|
520
|
+
|
|
521
|
+
``targets`` are the live position rows the dispatch must
|
|
522
|
+
close. Implementations issue one DELETE per target and return
|
|
523
|
+
a :class:`CloseOutcome` with ``mode='full'`` and
|
|
524
|
+
``applied_targets`` listing the ``dealId`` of every
|
|
525
|
+
successfully DELETEd position. On a benign already-gone race
|
|
526
|
+
(404) targets may be omitted from ``applied_targets``; the
|
|
527
|
+
recovery contract treats vanished targets as confirmed.
|
|
528
|
+
|
|
529
|
+
Implementations MUST NOT mutate the close command row's state
|
|
530
|
+
— only the journal does that. They MAY mutate the *target*
|
|
531
|
+
rows (e.g. ``store.set_order_state(target_coid, 'closing')``)
|
|
532
|
+
because those rows live outside the journal's command-row
|
|
533
|
+
scope.
|
|
534
|
+
|
|
535
|
+
Network / timeout errors raise
|
|
536
|
+
:class:`OrderDispositionUnknownError`; explicit broker rejects
|
|
537
|
+
raise :class:`ExchangeOrderRejectedError`.
|
|
538
|
+
"""
|
|
539
|
+
...
|
|
540
|
+
|
|
541
|
+
async def submit_partial_close(
|
|
542
|
+
self, *, coid: str, intent: CloseIntent,
|
|
543
|
+
) -> CloseOutcome:
|
|
544
|
+
"""Emulated partial close via opposite-direction POST.
|
|
545
|
+
|
|
546
|
+
Implementations issue a single POST (Capital.com has no native
|
|
547
|
+
partial-close endpoint), record the ``dealReference``, and
|
|
548
|
+
reconcile pre/post position snapshots to detect any race
|
|
549
|
+
against an unrelated opposite-side opening. Returns a
|
|
550
|
+
:class:`CloseOutcome` with ``mode='partial'``,
|
|
551
|
+
``deal_reference`` populated, ``exchange_id`` set to the new
|
|
552
|
+
opposite-leg ``dealId``, and ``applied_targets`` listing that
|
|
553
|
+
single ``dealId``. The hook itself raises
|
|
554
|
+
:class:`BrokerManualInterventionError` on an unresolved race;
|
|
555
|
+
the journal does not catch that — it propagates to the engine.
|
|
556
|
+
"""
|
|
557
|
+
...
|
|
558
|
+
|
|
559
|
+
def exchange_order_from_state(
|
|
560
|
+
self, *, row: 'OrderRow', intent: CloseIntent,
|
|
561
|
+
outcome: CloseOutcome,
|
|
562
|
+
) -> ExchangeOrder:
|
|
563
|
+
"""Build the :class:`ExchangeOrder` the engine expects.
|
|
564
|
+
|
|
565
|
+
Called once the command row has reached its terminal state
|
|
566
|
+
(``closing`` for full, ``confirmed`` for partial). Pure
|
|
567
|
+
function — no I/O.
|
|
568
|
+
"""
|
|
569
|
+
...
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class CancelDispatchHooks(Protocol):
|
|
573
|
+
"""Plugin-supplied callbacks for a cancel dispatch.
|
|
574
|
+
|
|
575
|
+
Cancel has no submit/confirm split — the plugin sweeps all targets
|
|
576
|
+
in a single call and returns the outcome. The journal owns the
|
|
577
|
+
command-row state transitions; the plugin owns the per-target
|
|
578
|
+
REST operations and the target-row mutations.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
async def submit_cancel(
|
|
582
|
+
self, *, coid: str, intent: CancelIntent,
|
|
583
|
+
targets: list['OrderRow'],
|
|
584
|
+
) -> CancelOutcome:
|
|
585
|
+
"""Sweep every target. Returns a single :class:`CancelOutcome`.
|
|
586
|
+
|
|
587
|
+
Implementations issue the per-target REST calls (DELETE for
|
|
588
|
+
working orders, PUT-null for bracket legs) and mark the
|
|
589
|
+
target rows closed via ``store.close_order(target_coid)``.
|
|
590
|
+
Benign already-gone (404) responses are absorbed without
|
|
591
|
+
raising — the resulting :attr:`CancelOutcome.reason_path`
|
|
592
|
+
reflects whether any actual DELETE happened.
|
|
593
|
+
|
|
594
|
+
Implementations MUST NOT mutate the cancel command row's
|
|
595
|
+
state — only the journal does that.
|
|
596
|
+
"""
|
|
597
|
+
...
|
|
598
|
+
|
|
599
|
+
def exchange_order_from_state(
|
|
600
|
+
self, *, row: 'OrderRow', intent: CancelIntent,
|
|
601
|
+
outcome: CancelOutcome,
|
|
602
|
+
) -> ExchangeOrder:
|
|
603
|
+
"""Build the synthetic :class:`ExchangeOrder` for the cancel.
|
|
604
|
+
|
|
605
|
+
Cancel does not produce an exchange order per se, but the
|
|
606
|
+
engine signature expects one. The hook synthesises a
|
|
607
|
+
:class:`OrderStatus.CANCELLED` order so the caller can plumb
|
|
608
|
+
the outcome through unchanged channels.
|
|
609
|
+
"""
|
|
610
|
+
...
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
class ModifyEntryDispatchHooks(Protocol):
|
|
614
|
+
"""Plugin-supplied callbacks for a working-order amend dispatch."""
|
|
615
|
+
|
|
616
|
+
async def submit_amend(
|
|
617
|
+
self, *, coid: str, target_coid: str,
|
|
618
|
+
old_intent: EntryIntent, new_intent: EntryIntent,
|
|
619
|
+
) -> ModifyEntryOutcome:
|
|
620
|
+
"""PUT the new level and confirm.
|
|
621
|
+
|
|
622
|
+
Returns a :class:`ModifyEntryOutcome` with the broker-echoed
|
|
623
|
+
``new_level``. On reject raises
|
|
624
|
+
:class:`ExchangeOrderRejectedError`; on timeout raises
|
|
625
|
+
:class:`OrderDispositionUnknownError`. The amend target row
|
|
626
|
+
(the working order itself) is mutated by the hook via
|
|
627
|
+
``store.upsert_order(target_coid, ...)`` because it lives
|
|
628
|
+
outside the journal's command-row scope.
|
|
629
|
+
"""
|
|
630
|
+
...
|
|
631
|
+
|
|
632
|
+
def exchange_order_from_state(
|
|
633
|
+
self, *, row: 'OrderRow', new_intent: EntryIntent,
|
|
634
|
+
outcome: ModifyEntryOutcome,
|
|
635
|
+
) -> list[ExchangeOrder]:
|
|
636
|
+
"""Build the :class:`ExchangeOrder` list for the engine.
|
|
637
|
+
|
|
638
|
+
The engine's :meth:`modify_entry` signature returns a list of
|
|
639
|
+
orders; for atomic amends this is a one-element list pointing
|
|
640
|
+
at the same target as before. Pure function.
|
|
641
|
+
"""
|
|
642
|
+
...
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class ModifyExitDispatchHooks(Protocol):
|
|
646
|
+
"""Plugin-supplied callbacks for a position bracket amend dispatch.
|
|
647
|
+
|
|
648
|
+
Modify-exit is the most complex dispatch — the plugin's
|
|
649
|
+
``prepare()`` logic decides the new TP / SL / trailing levels and
|
|
650
|
+
seeds any newly-added bracket leg rows in
|
|
651
|
+
``disposition_unknown``. The journal then drives the entry-row
|
|
652
|
+
audit trail and the ``mirror_bracket_legs`` callback that
|
|
653
|
+
materialises the synthetic legs on success.
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
async def submit_amend(
|
|
657
|
+
self, *, coid: str, target_coid: str,
|
|
658
|
+
old_intent: ExitIntent, new_intent: ExitIntent,
|
|
659
|
+
) -> ModifyExitOutcome:
|
|
660
|
+
"""PUT the new bracket and confirm.
|
|
661
|
+
|
|
662
|
+
On the happy path returns a :class:`ModifyExitOutcome` with
|
|
663
|
+
``deal_status='ACCEPTED'`` and ``post_put_state`` filled.
|
|
664
|
+
On reject raises :class:`ExchangeOrderRejectedError`; on
|
|
665
|
+
timeout raises :class:`OrderDispositionUnknownError`. Before
|
|
666
|
+
raising, the hook flips any leg rows it pre-seeded into the
|
|
667
|
+
appropriate disposition-unknown state and persists the
|
|
668
|
+
attempted target levels under the leg rows' extras — those
|
|
669
|
+
side-channel writes are part of the hook's responsibility.
|
|
670
|
+
"""
|
|
671
|
+
...
|
|
672
|
+
|
|
673
|
+
def mirror_bracket_legs(
|
|
674
|
+
self, *, target_row: 'OrderRow', new_intent: ExitIntent,
|
|
675
|
+
outcome: ModifyExitOutcome,
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Materialise synthetic TP / SL leg rows after success.
|
|
678
|
+
|
|
679
|
+
Invoked by the journal only on the happy path (after the
|
|
680
|
+
entry-side command row has transitioned to ``confirmed``).
|
|
681
|
+
On any ambiguous / reject path the journal does NOT call
|
|
682
|
+
this hook; the disposition-unknown leg seeds the
|
|
683
|
+
``submit_amend`` hook already wrote remain the source of
|
|
684
|
+
truth for recovery. Pure plugin-side write — uses
|
|
685
|
+
``store.upsert_order(leg_coid, ...)`` directly because the
|
|
686
|
+
synthetic leg rows are outside the journal's command-row
|
|
687
|
+
scope for M4.
|
|
688
|
+
"""
|
|
689
|
+
...
|
|
690
|
+
|
|
691
|
+
def exchange_order_from_state(
|
|
692
|
+
self, *, row: 'OrderRow', new_intent: ExitIntent,
|
|
693
|
+
outcome: ModifyExitOutcome,
|
|
694
|
+
) -> list[ExchangeOrder]:
|
|
695
|
+
"""Build the engine-facing :class:`ExchangeOrder` list.
|
|
696
|
+
|
|
697
|
+
Returns one :class:`ExchangeOrder` per active bracket leg
|
|
698
|
+
(TP / SL) reflecting the post-amend levels. Pure function.
|
|
699
|
+
"""
|
|
700
|
+
...
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# === Journal ===============================================================
|
|
704
|
+
|
|
705
|
+
@dataclass
|
|
706
|
+
class DispatchJournal:
|
|
707
|
+
"""Persist-first orchestrator for a single dispatch lifecycle.
|
|
708
|
+
|
|
709
|
+
Constructed once per :class:`~pynecore.core.broker.storage.RunContext`.
|
|
710
|
+
Reused across dispatches — the instance is thread-safe to the same
|
|
711
|
+
extent the underlying ``RunContext`` is (single writer assumption).
|
|
712
|
+
|
|
713
|
+
:param store: The active run context.
|
|
714
|
+
"""
|
|
715
|
+
store: 'RunContext'
|
|
716
|
+
|
|
717
|
+
# --- Entry path --------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
async def run_entry(
|
|
720
|
+
self,
|
|
721
|
+
*,
|
|
722
|
+
coid: str,
|
|
723
|
+
intent: EntryIntent,
|
|
724
|
+
qty: float,
|
|
725
|
+
kind: str,
|
|
726
|
+
hooks: EntryDispatchHooks,
|
|
727
|
+
audit_payload: dict | None = None,
|
|
728
|
+
) -> list[ExchangeOrder]:
|
|
729
|
+
"""Run an entry dispatch from initial persist through confirm.
|
|
730
|
+
|
|
731
|
+
The state-machine is :data:`STATE_SUBMITTED` → optionally
|
|
732
|
+
:data:`STATE_SERVER_REF_SEEN` → :data:`STATE_CONFIRMED` (or
|
|
733
|
+
:data:`STATE_REJECTED` / :data:`STATE_DISPOSITION_UNKNOWN` on
|
|
734
|
+
the failure paths). Every state advance happens through a
|
|
735
|
+
store helper, so the on-disk schema is identical to what the
|
|
736
|
+
legacy plugin path writes — the parity test relies on that.
|
|
737
|
+
|
|
738
|
+
:param coid: The dispatch's canonical client-order-id (already
|
|
739
|
+
derived from ``envelope.client_order_id(KIND_ENTRY)``).
|
|
740
|
+
:param intent: The :class:`EntryIntent` being dispatched.
|
|
741
|
+
:param qty: Quantity to submit, already quantized to the
|
|
742
|
+
broker's lot step.
|
|
743
|
+
:param kind: :data:`ENTRY_KIND_POSITION` for MARKET orders or
|
|
744
|
+
:data:`ENTRY_KIND_WORKING` for LIMIT / STOP. Decides the
|
|
745
|
+
``extras['kind']`` value.
|
|
746
|
+
:param hooks: Plugin-supplied callbacks (see
|
|
747
|
+
:class:`EntryDispatchHooks`).
|
|
748
|
+
:param audit_payload: Extra fields merged into the
|
|
749
|
+
``dispatch_submitted`` audit event payload. Optional;
|
|
750
|
+
typically the plugin's endpoint + body so forensics can
|
|
751
|
+
reconstruct the exact request.
|
|
752
|
+
:return: A one-element list with the resulting
|
|
753
|
+
:class:`ExchangeOrder` — matches the
|
|
754
|
+
:meth:`BrokerPlugin.execute_entry` signature.
|
|
755
|
+
:raises OrderDispositionUnknownError: If ``submit`` reported
|
|
756
|
+
an ambiguous outcome.
|
|
757
|
+
:raises ExchangeOrderRejectedError: If ``confirm_submission``
|
|
758
|
+
reported a definitive reject.
|
|
759
|
+
"""
|
|
760
|
+
# (1) PERSIST submitted row + audit event.
|
|
761
|
+
create_entry_order_row(
|
|
762
|
+
self.store,
|
|
763
|
+
coid=coid,
|
|
764
|
+
symbol=intent.symbol,
|
|
765
|
+
side=intent.side,
|
|
766
|
+
qty=qty,
|
|
767
|
+
intent_key=intent.intent_key,
|
|
768
|
+
pine_entry_id=intent.pine_id,
|
|
769
|
+
kind=kind,
|
|
770
|
+
order_type=intent.order_type.value,
|
|
771
|
+
)
|
|
772
|
+
submit_payload = {'kind': kind, 'order_type': intent.order_type.value}
|
|
773
|
+
if audit_payload:
|
|
774
|
+
submit_payload.update(audit_payload)
|
|
775
|
+
self.store.log_event(
|
|
776
|
+
'dispatch_submitted',
|
|
777
|
+
client_order_id=coid,
|
|
778
|
+
intent_key=intent.intent_key,
|
|
779
|
+
payload=submit_payload,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# (2) SUBMIT — network errors raise OrderDispositionUnknownError.
|
|
783
|
+
# A synchronous reject (4xx with a reason string) raises
|
|
784
|
+
# ExchangeOrderRejectedError; the canonical pattern is to defer
|
|
785
|
+
# rejection to the confirm phase, but a plugin may legitimately
|
|
786
|
+
# raise here when the POST itself definitively rejects (e.g. a
|
|
787
|
+
# well-formed exchange error response with no server reference
|
|
788
|
+
# to confirm against). The journal terminates the row safely
|
|
789
|
+
# either way — leaving it ``submitted`` would make it look
|
|
790
|
+
# pending and recovery would retry an order the exchange already
|
|
791
|
+
# rejected.
|
|
792
|
+
try:
|
|
793
|
+
submit = await hooks.submit(coid=coid, intent=intent, qty=qty)
|
|
794
|
+
except OrderDispositionUnknownError as exc:
|
|
795
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
796
|
+
self.store.log_event(
|
|
797
|
+
'disposition_unknown',
|
|
798
|
+
client_order_id=coid,
|
|
799
|
+
intent_key=intent.intent_key,
|
|
800
|
+
payload={'phase': 'submit', 'reason': str(exc)},
|
|
801
|
+
)
|
|
802
|
+
raise
|
|
803
|
+
except ExchangeOrderRejectedError as exc:
|
|
804
|
+
mark_rejected(self.store, coid=coid)
|
|
805
|
+
self.store.log_event(
|
|
806
|
+
'rejected',
|
|
807
|
+
client_order_id=coid,
|
|
808
|
+
intent_key=intent.intent_key,
|
|
809
|
+
payload={'phase': 'submit', 'reason': str(exc)},
|
|
810
|
+
)
|
|
811
|
+
raise
|
|
812
|
+
|
|
813
|
+
# (3) PERSIST server reference + advance state.
|
|
814
|
+
record_server_ref(
|
|
815
|
+
self.store,
|
|
816
|
+
coid=coid,
|
|
817
|
+
deal_reference=submit.server_ref,
|
|
818
|
+
kind=kind,
|
|
819
|
+
order_type=intent.order_type.value,
|
|
820
|
+
)
|
|
821
|
+
self.store.log_event(
|
|
822
|
+
'deal_reference_seen',
|
|
823
|
+
client_order_id=coid,
|
|
824
|
+
payload={'deal_reference': submit.server_ref},
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
# (4) CONFIRM — reject raises ExchangeOrderRejectedError,
|
|
828
|
+
# timeout raises OrderDispositionUnknownError.
|
|
829
|
+
try:
|
|
830
|
+
confirm = await hooks.confirm_submission(
|
|
831
|
+
coid=coid, intent=intent, server_ref=submit.server_ref,
|
|
832
|
+
)
|
|
833
|
+
except ExchangeOrderRejectedError as exc:
|
|
834
|
+
mark_rejected(self.store, coid=coid)
|
|
835
|
+
self.store.log_event(
|
|
836
|
+
'rejected',
|
|
837
|
+
client_order_id=coid,
|
|
838
|
+
intent_key=intent.intent_key,
|
|
839
|
+
payload={'reason': str(exc)},
|
|
840
|
+
)
|
|
841
|
+
raise
|
|
842
|
+
except OrderDispositionUnknownError as exc:
|
|
843
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
844
|
+
self.store.log_event(
|
|
845
|
+
'disposition_unknown',
|
|
846
|
+
client_order_id=coid,
|
|
847
|
+
intent_key=intent.intent_key,
|
|
848
|
+
payload={'phase': 'confirm', 'reason': str(exc)},
|
|
849
|
+
)
|
|
850
|
+
raise
|
|
851
|
+
|
|
852
|
+
# (5) PERSIST confirmed + fill (if any).
|
|
853
|
+
mark_confirmed_with_fill(
|
|
854
|
+
self.store,
|
|
855
|
+
coid=coid,
|
|
856
|
+
exchange_id=confirm.exchange_id,
|
|
857
|
+
is_filled=confirm.is_filled,
|
|
858
|
+
filled_qty=confirm.filled_qty,
|
|
859
|
+
fill_price=confirm.fill_price,
|
|
860
|
+
)
|
|
861
|
+
self.store.log_event(
|
|
862
|
+
'confirmed',
|
|
863
|
+
client_order_id=coid,
|
|
864
|
+
exchange_order_id=confirm.exchange_id,
|
|
865
|
+
intent_key=intent.intent_key,
|
|
866
|
+
payload={
|
|
867
|
+
'is_filled': confirm.is_filled,
|
|
868
|
+
'fill_price': confirm.fill_price,
|
|
869
|
+
},
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# (6) Return the ExchangeOrder built from final row state.
|
|
873
|
+
row = self.store.get_order(coid)
|
|
874
|
+
if row is None:
|
|
875
|
+
raise RuntimeError(
|
|
876
|
+
f"DispatchJournal.run_entry: row vanished after confirm "
|
|
877
|
+
f"(coid={coid!r})"
|
|
878
|
+
)
|
|
879
|
+
return [hooks.exchange_order_from_state(row=row, intent=intent)]
|
|
880
|
+
|
|
881
|
+
# --- Close path --------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
async def run_close(
|
|
884
|
+
self,
|
|
885
|
+
*,
|
|
886
|
+
coid: str,
|
|
887
|
+
intent: CloseIntent,
|
|
888
|
+
kind: str,
|
|
889
|
+
targets: list['OrderRow'],
|
|
890
|
+
hooks: CloseDispatchHooks,
|
|
891
|
+
audit_payload: dict | None = None,
|
|
892
|
+
) -> ExchangeOrder:
|
|
893
|
+
"""Run a close dispatch.
|
|
894
|
+
|
|
895
|
+
Routes between full-close (DELETE chain) and partial-close
|
|
896
|
+
(emulated POST) based on ``kind``. Each branch persists the
|
|
897
|
+
command row, calls the matching hook, and finalises the row.
|
|
898
|
+
Target-row mutations live in the hook, since they are outside
|
|
899
|
+
the command-row state-machine the journal owns.
|
|
900
|
+
|
|
901
|
+
:param coid: Close dispatch COID.
|
|
902
|
+
:param intent: The :class:`CloseIntent` being dispatched.
|
|
903
|
+
:param kind: :data:`KIND_FULL_CLOSE` or
|
|
904
|
+
:data:`KIND_PARTIAL_CLOSE`.
|
|
905
|
+
:param targets: Live position rows the dispatch should close.
|
|
906
|
+
For partial close this is the pre-existing rows (used by
|
|
907
|
+
the hook to derive the pre-snapshot delta), for full close
|
|
908
|
+
this drives the DELETE loop.
|
|
909
|
+
:param hooks: Plugin callbacks.
|
|
910
|
+
:param audit_payload: Optional extras to merge into the
|
|
911
|
+
initial ``dispatch_submitted`` event payload.
|
|
912
|
+
"""
|
|
913
|
+
if kind == KIND_FULL_CLOSE:
|
|
914
|
+
return await self._run_full_close(
|
|
915
|
+
coid=coid, intent=intent, targets=targets,
|
|
916
|
+
hooks=hooks, audit_payload=audit_payload,
|
|
917
|
+
)
|
|
918
|
+
if kind == KIND_PARTIAL_CLOSE:
|
|
919
|
+
return await self._run_partial_close(
|
|
920
|
+
coid=coid, intent=intent,
|
|
921
|
+
hooks=hooks, audit_payload=audit_payload,
|
|
922
|
+
)
|
|
923
|
+
raise ValueError(
|
|
924
|
+
f"DispatchJournal.run_close: kind must be one of "
|
|
925
|
+
f"{{KIND_FULL_CLOSE, KIND_PARTIAL_CLOSE}}, got {kind!r}"
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
async def _run_full_close(
|
|
929
|
+
self,
|
|
930
|
+
*,
|
|
931
|
+
coid: str,
|
|
932
|
+
intent: CloseIntent,
|
|
933
|
+
targets: list['OrderRow'],
|
|
934
|
+
hooks: CloseDispatchHooks,
|
|
935
|
+
audit_payload: dict | None,
|
|
936
|
+
) -> ExchangeOrder:
|
|
937
|
+
# (1) PERSIST command row + audit event.
|
|
938
|
+
target_ids = [r.exchange_order_id for r in targets]
|
|
939
|
+
create_close_target_row(
|
|
940
|
+
self.store,
|
|
941
|
+
coid=coid,
|
|
942
|
+
symbol=intent.symbol,
|
|
943
|
+
side=intent.side,
|
|
944
|
+
qty=intent.qty,
|
|
945
|
+
intent_key=intent.intent_key,
|
|
946
|
+
kind=KIND_FULL_CLOSE,
|
|
947
|
+
extra_payload={'targets': list(target_ids)},
|
|
948
|
+
)
|
|
949
|
+
submit_payload: dict[str, Any] = {
|
|
950
|
+
'kind': KIND_FULL_CLOSE,
|
|
951
|
+
'targets': target_ids,
|
|
952
|
+
}
|
|
953
|
+
if audit_payload:
|
|
954
|
+
submit_payload.update(audit_payload)
|
|
955
|
+
self.store.log_event(
|
|
956
|
+
'dispatch_submitted',
|
|
957
|
+
client_order_id=coid,
|
|
958
|
+
intent_key=intent.intent_key,
|
|
959
|
+
payload=submit_payload,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# (2) SUBMIT — per-target DELETE chain inside the hook.
|
|
963
|
+
try:
|
|
964
|
+
outcome = await hooks.submit_full_close(
|
|
965
|
+
coid=coid, intent=intent, targets=targets,
|
|
966
|
+
)
|
|
967
|
+
except OrderDispositionUnknownError as exc:
|
|
968
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
969
|
+
self.store.log_event(
|
|
970
|
+
'disposition_unknown',
|
|
971
|
+
client_order_id=coid,
|
|
972
|
+
intent_key=intent.intent_key,
|
|
973
|
+
payload={'phase': 'full_close_delete', 'reason': str(exc)},
|
|
974
|
+
)
|
|
975
|
+
raise
|
|
976
|
+
except ExchangeOrderRejectedError as exc:
|
|
977
|
+
mark_rejected(self.store, coid=coid)
|
|
978
|
+
self.store.log_event(
|
|
979
|
+
'rejected',
|
|
980
|
+
client_order_id=coid,
|
|
981
|
+
intent_key=intent.intent_key,
|
|
982
|
+
payload={'phase': 'full_close_delete', 'reason': str(exc)},
|
|
983
|
+
)
|
|
984
|
+
raise
|
|
985
|
+
|
|
986
|
+
# (3) PERSIST closing state + targets.
|
|
987
|
+
mark_closing(
|
|
988
|
+
self.store,
|
|
989
|
+
coid=coid,
|
|
990
|
+
kind=KIND_FULL_CLOSE,
|
|
991
|
+
targets=outcome.applied_targets,
|
|
992
|
+
)
|
|
993
|
+
self.store.log_event(
|
|
994
|
+
'close_dispatched',
|
|
995
|
+
client_order_id=coid,
|
|
996
|
+
intent_key=intent.intent_key,
|
|
997
|
+
payload={'mode': 'full', 'applied_targets': outcome.applied_targets},
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
# (4) Return the synthetic ExchangeOrder built by the hook.
|
|
1001
|
+
row = self.store.get_order(coid)
|
|
1002
|
+
if row is None:
|
|
1003
|
+
raise RuntimeError(
|
|
1004
|
+
f"DispatchJournal._run_full_close: row vanished after closing "
|
|
1005
|
+
f"(coid={coid!r})"
|
|
1006
|
+
)
|
|
1007
|
+
return hooks.exchange_order_from_state(
|
|
1008
|
+
row=row, intent=intent, outcome=outcome,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
async def _run_partial_close(
|
|
1012
|
+
self,
|
|
1013
|
+
*,
|
|
1014
|
+
coid: str,
|
|
1015
|
+
intent: CloseIntent,
|
|
1016
|
+
hooks: CloseDispatchHooks,
|
|
1017
|
+
audit_payload: dict | None,
|
|
1018
|
+
) -> ExchangeOrder:
|
|
1019
|
+
# (1) PERSIST command row + audit event.
|
|
1020
|
+
create_close_target_row(
|
|
1021
|
+
self.store,
|
|
1022
|
+
coid=coid,
|
|
1023
|
+
symbol=intent.symbol,
|
|
1024
|
+
side=intent.side,
|
|
1025
|
+
qty=intent.qty,
|
|
1026
|
+
intent_key=intent.intent_key,
|
|
1027
|
+
kind=KIND_PARTIAL_CLOSE,
|
|
1028
|
+
)
|
|
1029
|
+
submit_payload: dict[str, Any] = {'kind': KIND_PARTIAL_CLOSE}
|
|
1030
|
+
if audit_payload:
|
|
1031
|
+
submit_payload.update(audit_payload)
|
|
1032
|
+
self.store.log_event(
|
|
1033
|
+
'dispatch_submitted',
|
|
1034
|
+
client_order_id=coid,
|
|
1035
|
+
intent_key=intent.intent_key,
|
|
1036
|
+
payload=submit_payload,
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# (2) SUBMIT — opposite-direction POST + race detection inside the hook.
|
|
1040
|
+
try:
|
|
1041
|
+
outcome = await hooks.submit_partial_close(
|
|
1042
|
+
coid=coid, intent=intent,
|
|
1043
|
+
)
|
|
1044
|
+
except OrderDispositionUnknownError as exc:
|
|
1045
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
1046
|
+
self.store.log_event(
|
|
1047
|
+
'disposition_unknown',
|
|
1048
|
+
client_order_id=coid,
|
|
1049
|
+
intent_key=intent.intent_key,
|
|
1050
|
+
payload={'phase': 'partial_close_post', 'reason': str(exc)},
|
|
1051
|
+
)
|
|
1052
|
+
raise
|
|
1053
|
+
except ExchangeOrderRejectedError as exc:
|
|
1054
|
+
mark_rejected(self.store, coid=coid)
|
|
1055
|
+
self.store.log_event(
|
|
1056
|
+
'rejected',
|
|
1057
|
+
client_order_id=coid,
|
|
1058
|
+
intent_key=intent.intent_key,
|
|
1059
|
+
payload={'phase': 'partial_close_post', 'reason': str(exc)},
|
|
1060
|
+
)
|
|
1061
|
+
raise
|
|
1062
|
+
|
|
1063
|
+
# (3) PERSIST server ref (if any).
|
|
1064
|
+
if outcome.deal_reference is not None:
|
|
1065
|
+
record_close_server_ref(
|
|
1066
|
+
self.store,
|
|
1067
|
+
coid=coid,
|
|
1068
|
+
deal_reference=outcome.deal_reference,
|
|
1069
|
+
kind=KIND_PARTIAL_CLOSE,
|
|
1070
|
+
)
|
|
1071
|
+
self.store.log_event(
|
|
1072
|
+
'deal_reference_seen',
|
|
1073
|
+
client_order_id=coid,
|
|
1074
|
+
payload={'deal_reference': outcome.deal_reference},
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
# (4) PERSIST completion. The helper only advances state;
|
|
1078
|
+
# ``close_order`` is issued *after* the ``confirmed`` event so
|
|
1079
|
+
# the audit order is consistent.
|
|
1080
|
+
mark_close_completed(
|
|
1081
|
+
self.store,
|
|
1082
|
+
coid=coid,
|
|
1083
|
+
kind=KIND_PARTIAL_CLOSE,
|
|
1084
|
+
)
|
|
1085
|
+
self.store.log_event(
|
|
1086
|
+
'confirmed',
|
|
1087
|
+
client_order_id=coid,
|
|
1088
|
+
exchange_order_id=outcome.exchange_id,
|
|
1089
|
+
intent_key=intent.intent_key,
|
|
1090
|
+
payload={
|
|
1091
|
+
'mode': 'partial',
|
|
1092
|
+
'applied_targets': outcome.applied_targets,
|
|
1093
|
+
'fill_price': outcome.fill_price,
|
|
1094
|
+
},
|
|
1095
|
+
)
|
|
1096
|
+
self.store.close_order(coid)
|
|
1097
|
+
|
|
1098
|
+
# (5) Return the ExchangeOrder built by the hook.
|
|
1099
|
+
row = self.store.get_order(coid)
|
|
1100
|
+
if row is None:
|
|
1101
|
+
raise RuntimeError(
|
|
1102
|
+
f"DispatchJournal._run_partial_close: row vanished after confirm "
|
|
1103
|
+
f"(coid={coid!r})"
|
|
1104
|
+
)
|
|
1105
|
+
return hooks.exchange_order_from_state(
|
|
1106
|
+
row=row, intent=intent, outcome=outcome,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# --- Cancel path -------------------------------------------------------
|
|
1110
|
+
|
|
1111
|
+
async def run_cancel(
|
|
1112
|
+
self,
|
|
1113
|
+
*,
|
|
1114
|
+
coid: str,
|
|
1115
|
+
intent: CancelIntent,
|
|
1116
|
+
targets: list['OrderRow'],
|
|
1117
|
+
hooks: CancelDispatchHooks,
|
|
1118
|
+
audit_payload: dict | None = None,
|
|
1119
|
+
) -> ExchangeOrder:
|
|
1120
|
+
"""Run a cancel dispatch.
|
|
1121
|
+
|
|
1122
|
+
The journal persists the command row, calls the hook for the
|
|
1123
|
+
per-target sweep, and finalises the row with the
|
|
1124
|
+
``reason_path`` from the outcome. The per-target REST calls
|
|
1125
|
+
and target-row mutations are owned by the hook.
|
|
1126
|
+
|
|
1127
|
+
:param coid: Cancel dispatch COID.
|
|
1128
|
+
:param intent: The :class:`CancelIntent` being dispatched.
|
|
1129
|
+
:param targets: Live rows the dispatch should cancel.
|
|
1130
|
+
:param hooks: Plugin callbacks.
|
|
1131
|
+
:param audit_payload: Optional extras for the
|
|
1132
|
+
``dispatch_submitted`` event payload.
|
|
1133
|
+
"""
|
|
1134
|
+
target_coids = [r.client_order_id for r in targets]
|
|
1135
|
+
agg_qty = sum(max(0.0, r.qty - r.filled_qty) for r in targets)
|
|
1136
|
+
primary_side = targets[0].side if targets else 'buy'
|
|
1137
|
+
|
|
1138
|
+
# (1) PERSIST command row + audit event.
|
|
1139
|
+
create_cancel_command_row(
|
|
1140
|
+
self.store,
|
|
1141
|
+
coid=coid,
|
|
1142
|
+
symbol=intent.symbol,
|
|
1143
|
+
side=primary_side,
|
|
1144
|
+
qty=agg_qty,
|
|
1145
|
+
intent_key=intent.intent_key,
|
|
1146
|
+
pine_entry_id=intent.pine_id,
|
|
1147
|
+
from_entry=intent.from_entry,
|
|
1148
|
+
target_coids=target_coids,
|
|
1149
|
+
)
|
|
1150
|
+
submit_payload: dict[str, Any] = {
|
|
1151
|
+
'kind': KIND_CANCEL,
|
|
1152
|
+
'target_coids': target_coids,
|
|
1153
|
+
}
|
|
1154
|
+
if audit_payload:
|
|
1155
|
+
submit_payload.update(audit_payload)
|
|
1156
|
+
self.store.log_event(
|
|
1157
|
+
'dispatch_submitted',
|
|
1158
|
+
client_order_id=coid,
|
|
1159
|
+
intent_key=intent.intent_key,
|
|
1160
|
+
payload=submit_payload,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# (2) SUBMIT — per-target sweep inside the hook.
|
|
1164
|
+
try:
|
|
1165
|
+
outcome = await hooks.submit_cancel(
|
|
1166
|
+
coid=coid, intent=intent, targets=targets,
|
|
1167
|
+
)
|
|
1168
|
+
except OrderDispositionUnknownError as exc:
|
|
1169
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
1170
|
+
self.store.log_event(
|
|
1171
|
+
'disposition_unknown',
|
|
1172
|
+
client_order_id=coid,
|
|
1173
|
+
intent_key=intent.intent_key,
|
|
1174
|
+
payload={'phase': 'cancel_sweep', 'reason': str(exc)},
|
|
1175
|
+
)
|
|
1176
|
+
raise
|
|
1177
|
+
except ExchangeOrderRejectedError as exc:
|
|
1178
|
+
mark_rejected(self.store, coid=coid)
|
|
1179
|
+
self.store.log_event(
|
|
1180
|
+
'rejected',
|
|
1181
|
+
client_order_id=coid,
|
|
1182
|
+
intent_key=intent.intent_key,
|
|
1183
|
+
payload={'phase': 'cancel_sweep', 'reason': str(exc)},
|
|
1184
|
+
)
|
|
1185
|
+
raise
|
|
1186
|
+
|
|
1187
|
+
# (3) PERSIST completion with reason_path.
|
|
1188
|
+
mark_cancel_completed(
|
|
1189
|
+
self.store,
|
|
1190
|
+
coid=coid,
|
|
1191
|
+
reason_path=outcome.reason_path,
|
|
1192
|
+
extra_payload={
|
|
1193
|
+
'applied_target_coids': outcome.applied_target_coids,
|
|
1194
|
+
},
|
|
1195
|
+
)
|
|
1196
|
+
self.store.log_event(
|
|
1197
|
+
'confirmed',
|
|
1198
|
+
client_order_id=coid,
|
|
1199
|
+
intent_key=intent.intent_key,
|
|
1200
|
+
payload={
|
|
1201
|
+
'reason_path': outcome.reason_path,
|
|
1202
|
+
'cleared_legs': outcome.cleared_legs,
|
|
1203
|
+
'applied_target_coids': outcome.applied_target_coids,
|
|
1204
|
+
},
|
|
1205
|
+
)
|
|
1206
|
+
self.store.close_order(coid)
|
|
1207
|
+
|
|
1208
|
+
# (4) Return the synthetic ExchangeOrder built by the hook.
|
|
1209
|
+
row = self.store.get_order(coid)
|
|
1210
|
+
if row is None:
|
|
1211
|
+
raise RuntimeError(
|
|
1212
|
+
f"DispatchJournal.run_cancel: row vanished after confirm "
|
|
1213
|
+
f"(coid={coid!r})"
|
|
1214
|
+
)
|
|
1215
|
+
return hooks.exchange_order_from_state(
|
|
1216
|
+
row=row, intent=intent, outcome=outcome,
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
# --- Modify entry path -------------------------------------------------
|
|
1220
|
+
|
|
1221
|
+
async def run_modify_entry(
|
|
1222
|
+
self,
|
|
1223
|
+
*,
|
|
1224
|
+
coid: str,
|
|
1225
|
+
target_coid: str,
|
|
1226
|
+
old_intent: EntryIntent,
|
|
1227
|
+
new_intent: EntryIntent,
|
|
1228
|
+
qty: float,
|
|
1229
|
+
hooks: ModifyEntryDispatchHooks,
|
|
1230
|
+
audit_payload: dict | None = None,
|
|
1231
|
+
) -> list[ExchangeOrder]:
|
|
1232
|
+
"""Run an atomic working-order amend dispatch.
|
|
1233
|
+
|
|
1234
|
+
:param coid: COID of the amend command row.
|
|
1235
|
+
:param target_coid: COID of the working order being amended.
|
|
1236
|
+
:param old_intent: Intent before the amend (for audit context).
|
|
1237
|
+
:param new_intent: Intent the broker should land.
|
|
1238
|
+
:param qty: Order quantity (unchanged across the amend).
|
|
1239
|
+
:param hooks: Plugin callbacks.
|
|
1240
|
+
:param audit_payload: Optional extras for the
|
|
1241
|
+
``dispatch_submitted`` event payload.
|
|
1242
|
+
"""
|
|
1243
|
+
# (1) PERSIST command row + audit event.
|
|
1244
|
+
new_level = float(new_intent.limit if new_intent.limit is not None
|
|
1245
|
+
else new_intent.stop or 0.0)
|
|
1246
|
+
create_modify_entry_row(
|
|
1247
|
+
self.store,
|
|
1248
|
+
coid=coid,
|
|
1249
|
+
target_coid=target_coid,
|
|
1250
|
+
symbol=new_intent.symbol,
|
|
1251
|
+
side=new_intent.side,
|
|
1252
|
+
qty=qty,
|
|
1253
|
+
intent_key=new_intent.intent_key,
|
|
1254
|
+
new_level=new_level,
|
|
1255
|
+
pine_entry_id=new_intent.pine_id,
|
|
1256
|
+
)
|
|
1257
|
+
submit_payload: dict[str, Any] = {
|
|
1258
|
+
'kind': KIND_MODIFY_ENTRY,
|
|
1259
|
+
'target_coid': target_coid,
|
|
1260
|
+
'new_level': new_level,
|
|
1261
|
+
}
|
|
1262
|
+
if audit_payload:
|
|
1263
|
+
submit_payload.update(audit_payload)
|
|
1264
|
+
self.store.log_event(
|
|
1265
|
+
'dispatch_submitted',
|
|
1266
|
+
client_order_id=coid,
|
|
1267
|
+
intent_key=new_intent.intent_key,
|
|
1268
|
+
payload=submit_payload,
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
# (2) SUBMIT — PUT + confirm inside the hook.
|
|
1272
|
+
try:
|
|
1273
|
+
outcome = await hooks.submit_amend(
|
|
1274
|
+
coid=coid, target_coid=target_coid,
|
|
1275
|
+
old_intent=old_intent, new_intent=new_intent,
|
|
1276
|
+
)
|
|
1277
|
+
except OrderDispositionUnknownError as exc:
|
|
1278
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
1279
|
+
self.store.log_event(
|
|
1280
|
+
'disposition_unknown',
|
|
1281
|
+
client_order_id=coid,
|
|
1282
|
+
intent_key=new_intent.intent_key,
|
|
1283
|
+
payload={'phase': 'modify_entry_put', 'reason': str(exc)},
|
|
1284
|
+
)
|
|
1285
|
+
raise
|
|
1286
|
+
except ExchangeOrderRejectedError as exc:
|
|
1287
|
+
mark_rejected(self.store, coid=coid)
|
|
1288
|
+
self.store.log_event(
|
|
1289
|
+
'rejected',
|
|
1290
|
+
client_order_id=coid,
|
|
1291
|
+
intent_key=new_intent.intent_key,
|
|
1292
|
+
payload={'phase': 'modify_entry_put', 'reason': str(exc)},
|
|
1293
|
+
)
|
|
1294
|
+
raise
|
|
1295
|
+
|
|
1296
|
+
# (3) PERSIST server ref + completion.
|
|
1297
|
+
self.store.add_ref(coid, 'deal_reference', outcome.server_ref)
|
|
1298
|
+
self.store.log_event(
|
|
1299
|
+
'deal_reference_seen',
|
|
1300
|
+
client_order_id=coid,
|
|
1301
|
+
payload={'deal_reference': outcome.server_ref},
|
|
1302
|
+
)
|
|
1303
|
+
mark_modify_completed(
|
|
1304
|
+
self.store,
|
|
1305
|
+
coid=coid,
|
|
1306
|
+
extra_payload={'echoed_level': outcome.new_level},
|
|
1307
|
+
)
|
|
1308
|
+
self.store.log_event(
|
|
1309
|
+
'confirmed',
|
|
1310
|
+
client_order_id=coid,
|
|
1311
|
+
intent_key=new_intent.intent_key,
|
|
1312
|
+
payload={'new_level': outcome.new_level},
|
|
1313
|
+
)
|
|
1314
|
+
self.store.close_order(coid)
|
|
1315
|
+
|
|
1316
|
+
# (4) Return the engine-facing list.
|
|
1317
|
+
row = self.store.get_order(coid)
|
|
1318
|
+
if row is None:
|
|
1319
|
+
raise RuntimeError(
|
|
1320
|
+
f"DispatchJournal.run_modify_entry: row vanished after confirm "
|
|
1321
|
+
f"(coid={coid!r})"
|
|
1322
|
+
)
|
|
1323
|
+
return hooks.exchange_order_from_state(
|
|
1324
|
+
row=row, new_intent=new_intent, outcome=outcome,
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
# --- Modify exit path --------------------------------------------------
|
|
1328
|
+
|
|
1329
|
+
async def run_modify_exit(
|
|
1330
|
+
self,
|
|
1331
|
+
*,
|
|
1332
|
+
coid: str,
|
|
1333
|
+
target_coid: str,
|
|
1334
|
+
target_row: 'OrderRow',
|
|
1335
|
+
old_intent: ExitIntent,
|
|
1336
|
+
new_intent: ExitIntent,
|
|
1337
|
+
qty: float,
|
|
1338
|
+
hooks: ModifyExitDispatchHooks,
|
|
1339
|
+
audit_payload: dict | None = None,
|
|
1340
|
+
) -> list[ExchangeOrder]:
|
|
1341
|
+
"""Run a position bracket amend dispatch.
|
|
1342
|
+
|
|
1343
|
+
The journal owns the entry-side command row state machine and
|
|
1344
|
+
invokes :meth:`mirror_bracket_legs` on the happy path. The
|
|
1345
|
+
synthetic leg rows themselves are written by the hook (both
|
|
1346
|
+
the disposition-unknown seeds in the ambiguous path and the
|
|
1347
|
+
confirmed leg rows in the mirror path).
|
|
1348
|
+
|
|
1349
|
+
:param coid: COID of the amend command row.
|
|
1350
|
+
:param target_coid: COID of the entry row representing the
|
|
1351
|
+
position being amended.
|
|
1352
|
+
:param target_row: Live row of the target entry (passed to the
|
|
1353
|
+
mirror callback).
|
|
1354
|
+
:param old_intent: Intent before the amend.
|
|
1355
|
+
:param new_intent: Intent the broker should land.
|
|
1356
|
+
:param qty: Position quantity (unchanged across the amend).
|
|
1357
|
+
:param hooks: Plugin callbacks.
|
|
1358
|
+
:param audit_payload: Optional extras for the
|
|
1359
|
+
``dispatch_submitted`` event payload.
|
|
1360
|
+
"""
|
|
1361
|
+
# (1) PERSIST command row + audit event.
|
|
1362
|
+
create_modify_exit_row(
|
|
1363
|
+
self.store,
|
|
1364
|
+
coid=coid,
|
|
1365
|
+
target_coid=target_coid,
|
|
1366
|
+
symbol=new_intent.symbol,
|
|
1367
|
+
side=new_intent.side,
|
|
1368
|
+
qty=qty,
|
|
1369
|
+
intent_key=new_intent.intent_key,
|
|
1370
|
+
new_tp=new_intent.tp_price,
|
|
1371
|
+
new_sl=new_intent.sl_price,
|
|
1372
|
+
new_trail=new_intent.trail_offset,
|
|
1373
|
+
new_trail_price=new_intent.trail_price,
|
|
1374
|
+
pine_entry_id=new_intent.pine_id,
|
|
1375
|
+
from_entry=new_intent.from_entry,
|
|
1376
|
+
)
|
|
1377
|
+
submit_payload: dict[str, Any] = {
|
|
1378
|
+
'kind': KIND_MODIFY_EXIT,
|
|
1379
|
+
'target_coid': target_coid,
|
|
1380
|
+
'new_tp': new_intent.tp_price,
|
|
1381
|
+
'new_sl': new_intent.sl_price,
|
|
1382
|
+
'new_trail': new_intent.trail_offset,
|
|
1383
|
+
}
|
|
1384
|
+
if audit_payload:
|
|
1385
|
+
submit_payload.update(audit_payload)
|
|
1386
|
+
self.store.log_event(
|
|
1387
|
+
'dispatch_submitted',
|
|
1388
|
+
client_order_id=coid,
|
|
1389
|
+
intent_key=new_intent.intent_key,
|
|
1390
|
+
payload=submit_payload,
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
# (2) SUBMIT — PUT + confirm inside the hook; ambiguous-path
|
|
1394
|
+
# leg seeding is the hook's responsibility before re-raising.
|
|
1395
|
+
try:
|
|
1396
|
+
outcome = await hooks.submit_amend(
|
|
1397
|
+
coid=coid, target_coid=target_coid,
|
|
1398
|
+
old_intent=old_intent, new_intent=new_intent,
|
|
1399
|
+
)
|
|
1400
|
+
except OrderDispositionUnknownError as exc:
|
|
1401
|
+
mark_disposition_unknown(self.store, coid=coid)
|
|
1402
|
+
self.store.log_event(
|
|
1403
|
+
'disposition_unknown',
|
|
1404
|
+
client_order_id=coid,
|
|
1405
|
+
intent_key=new_intent.intent_key,
|
|
1406
|
+
payload={'phase': 'modify_exit_put', 'reason': str(exc)},
|
|
1407
|
+
)
|
|
1408
|
+
raise
|
|
1409
|
+
except ExchangeOrderRejectedError as exc:
|
|
1410
|
+
mark_rejected(self.store, coid=coid)
|
|
1411
|
+
self.store.log_event(
|
|
1412
|
+
'rejected',
|
|
1413
|
+
client_order_id=coid,
|
|
1414
|
+
intent_key=new_intent.intent_key,
|
|
1415
|
+
payload={'phase': 'modify_exit_put', 'reason': str(exc)},
|
|
1416
|
+
)
|
|
1417
|
+
raise
|
|
1418
|
+
|
|
1419
|
+
# (3) PERSIST server ref.
|
|
1420
|
+
self.store.add_ref(coid, 'deal_reference', outcome.server_ref)
|
|
1421
|
+
self.store.log_event(
|
|
1422
|
+
'deal_reference_seen',
|
|
1423
|
+
client_order_id=coid,
|
|
1424
|
+
payload={'deal_reference': outcome.server_ref},
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
# (4) MIRROR bracket legs BEFORE marking the command row terminal.
|
|
1428
|
+
# The command row stays live (and recoverable) until the legs are
|
|
1429
|
+
# written, so a crash between the broker-accepted PUT and the
|
|
1430
|
+
# mirror callback leaves recovery able to re-run the verdict
|
|
1431
|
+
# (confirms/{ref} → ACCEPTED) and replay the mirror on next sync
|
|
1432
|
+
# via the snapshot reconciler. Once mark_modify_completed +
|
|
1433
|
+
# close_order run, the row leaves the live set and the leg state
|
|
1434
|
+
# must already be durable.
|
|
1435
|
+
hooks.mirror_bracket_legs(
|
|
1436
|
+
target_row=target_row, new_intent=new_intent, outcome=outcome,
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
# (5) PERSIST terminal completion + close.
|
|
1440
|
+
mark_modify_completed(
|
|
1441
|
+
self.store,
|
|
1442
|
+
coid=coid,
|
|
1443
|
+
extra_payload={'post_put_state': dict(outcome.post_put_state)},
|
|
1444
|
+
)
|
|
1445
|
+
self.store.log_event(
|
|
1446
|
+
'confirmed',
|
|
1447
|
+
client_order_id=coid,
|
|
1448
|
+
intent_key=new_intent.intent_key,
|
|
1449
|
+
payload={
|
|
1450
|
+
'deal_status': outcome.deal_status,
|
|
1451
|
+
'post_put_state': dict(outcome.post_put_state),
|
|
1452
|
+
},
|
|
1453
|
+
)
|
|
1454
|
+
self.store.close_order(coid)
|
|
1455
|
+
|
|
1456
|
+
# (6) Return the engine-facing list.
|
|
1457
|
+
row = self.store.get_order(coid)
|
|
1458
|
+
if row is None:
|
|
1459
|
+
raise RuntimeError(
|
|
1460
|
+
f"DispatchJournal.run_modify_exit: row vanished after confirm "
|
|
1461
|
+
f"(coid={coid!r})"
|
|
1462
|
+
)
|
|
1463
|
+
return hooks.exchange_order_from_state(
|
|
1464
|
+
row=row, new_intent=new_intent, outcome=outcome,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
# --- Recovery path -----------------------------------------------------
|
|
1468
|
+
|
|
1469
|
+
async def recover_pending(
|
|
1470
|
+
self,
|
|
1471
|
+
hooks_for: 'PendingHooksProvider',
|
|
1472
|
+
) -> list['PendingResolution']:
|
|
1473
|
+
"""Replay every pending row through the plugin's resume hook.
|
|
1474
|
+
|
|
1475
|
+
Called once after :class:`~pynecore.core.broker.storage.BrokerStore.open_run`
|
|
1476
|
+
and before the first sync-engine iteration. The plugin
|
|
1477
|
+
supplies a callable that builds the per-row hook from the
|
|
1478
|
+
stored ``extras`` / refs; this keeps the journal free of
|
|
1479
|
+
plugin-specific construction logic.
|
|
1480
|
+
|
|
1481
|
+
:param hooks_for: Callable that returns either an
|
|
1482
|
+
:class:`EntryDispatchHooks` for an entry row, or
|
|
1483
|
+
``None`` if the row's ``extras['kind']`` is not handled
|
|
1484
|
+
by the entry journal (other kinds — bracket legs etc. —
|
|
1485
|
+
land in their own journals later).
|
|
1486
|
+
:return: One :class:`PendingResolution` per processed row,
|
|
1487
|
+
useful for diagnostics / tests.
|
|
1488
|
+
"""
|
|
1489
|
+
resolutions: list[PendingResolution] = []
|
|
1490
|
+
for row in find_pending_dispatch(self.store):
|
|
1491
|
+
hooks = hooks_for(row)
|
|
1492
|
+
if hooks is None:
|
|
1493
|
+
resolutions.append(PendingResolution(
|
|
1494
|
+
coid=row.client_order_id,
|
|
1495
|
+
status='skipped',
|
|
1496
|
+
reason='unhandled_kind',
|
|
1497
|
+
))
|
|
1498
|
+
continue
|
|
1499
|
+
refs = _collect_refs_for(self.store, coid=row.client_order_id)
|
|
1500
|
+
outcome = await hooks.resume_pending_dispatch(row=row, refs=refs)
|
|
1501
|
+
resolutions.append(self._apply_resume_outcome(row, outcome))
|
|
1502
|
+
return resolutions
|
|
1503
|
+
|
|
1504
|
+
def _apply_resume_outcome(
|
|
1505
|
+
self,
|
|
1506
|
+
row: 'OrderRow',
|
|
1507
|
+
outcome: ResumeOutcome,
|
|
1508
|
+
) -> 'PendingResolution':
|
|
1509
|
+
"""Persist the recovery verdict and return a diagnostic record.
|
|
1510
|
+
|
|
1511
|
+
The ``confirmed`` verdict's terminal-state writer depends on the
|
|
1512
|
+
row's ``extras['kind']``: entry rows (``ENTRY_KIND_POSITION`` /
|
|
1513
|
+
``ENTRY_KIND_WORKING``) stay live as the engine-facing order, so
|
|
1514
|
+
:func:`mark_confirmed_with_fill` is the right helper. Command
|
|
1515
|
+
rows are one-shot dispatch records — they need the matching
|
|
1516
|
+
terminal-state writer plus, except for ``KIND_FULL_CLOSE``, an
|
|
1517
|
+
explicit :meth:`RunContext.close_order` to leave the live-orders
|
|
1518
|
+
set:
|
|
1519
|
+
|
|
1520
|
+
* ``KIND_MODIFY_ENTRY`` → :func:`mark_modify_completed`
|
|
1521
|
+
* ``KIND_MODIFY_EXIT`` → :func:`mark_modify_completed` (same
|
|
1522
|
+
one-shot semantics as the entry-amend; the bracket leg rows
|
|
1523
|
+
are reconciled separately by the plugin's snapshot resolver
|
|
1524
|
+
and are outside this row's scope).
|
|
1525
|
+
* ``KIND_CANCEL`` → :func:`mark_cancel_completed`
|
|
1526
|
+
(``reason_path='recovered'``)
|
|
1527
|
+
* ``KIND_FULL_CLOSE`` → :func:`mark_closing` (the row stays
|
|
1528
|
+
live in ``closing`` state until the activity stream promotes
|
|
1529
|
+
each target to ``closed``).
|
|
1530
|
+
* ``KIND_PARTIAL_CLOSE`` → :func:`mark_close_completed`
|
|
1531
|
+
"""
|
|
1532
|
+
kind = (row.extras or {}).get('kind')
|
|
1533
|
+
if outcome.status == 'confirmed':
|
|
1534
|
+
if kind == KIND_MODIFY_ENTRY:
|
|
1535
|
+
mark_modify_completed(
|
|
1536
|
+
self.store,
|
|
1537
|
+
coid=row.client_order_id,
|
|
1538
|
+
extra_payload=(
|
|
1539
|
+
{'recovery_path': outcome.recovery_path}
|
|
1540
|
+
if outcome.recovery_path is not None else None
|
|
1541
|
+
),
|
|
1542
|
+
)
|
|
1543
|
+
self.store.close_order(row.client_order_id)
|
|
1544
|
+
elif kind == KIND_MODIFY_EXIT:
|
|
1545
|
+
extras_payload: dict[str, Any] = {}
|
|
1546
|
+
if outcome.recovery_path is not None:
|
|
1547
|
+
extras_payload['recovery_path'] = outcome.recovery_path
|
|
1548
|
+
if outcome.recovery_context is not None:
|
|
1549
|
+
extras_payload['recovery_context'] = dict(
|
|
1550
|
+
outcome.recovery_context
|
|
1551
|
+
)
|
|
1552
|
+
mark_modify_completed(
|
|
1553
|
+
self.store,
|
|
1554
|
+
coid=row.client_order_id,
|
|
1555
|
+
extra_payload=extras_payload or None,
|
|
1556
|
+
)
|
|
1557
|
+
self.store.close_order(row.client_order_id)
|
|
1558
|
+
elif kind == KIND_CANCEL:
|
|
1559
|
+
extra: dict[str, Any] = {}
|
|
1560
|
+
if outcome.recovery_path is not None:
|
|
1561
|
+
extra['recovery_path'] = outcome.recovery_path
|
|
1562
|
+
applied: list[str] | None = (outcome.recovery_context or {}).get(
|
|
1563
|
+
'applied_target_coids'
|
|
1564
|
+
) if outcome.recovery_context is not None else None
|
|
1565
|
+
if applied is not None:
|
|
1566
|
+
extra['applied_target_coids'] = list(applied)
|
|
1567
|
+
mark_cancel_completed(
|
|
1568
|
+
self.store,
|
|
1569
|
+
coid=row.client_order_id,
|
|
1570
|
+
reason_path='recovered',
|
|
1571
|
+
extra_payload=extra or None,
|
|
1572
|
+
)
|
|
1573
|
+
self.store.close_order(row.client_order_id)
|
|
1574
|
+
elif kind == KIND_FULL_CLOSE:
|
|
1575
|
+
# Recovery verdict says every DELETE landed (targets
|
|
1576
|
+
# vanished from the broker snapshot). Promote the command
|
|
1577
|
+
# row to ``closing`` — matching the live dispatch end
|
|
1578
|
+
# state — and leave it open; the activity stream then
|
|
1579
|
+
# closes each target row in due course.
|
|
1580
|
+
ctx = outcome.recovery_context or {}
|
|
1581
|
+
applied_targets = list(ctx.get('applied_targets') or [])
|
|
1582
|
+
if not applied_targets:
|
|
1583
|
+
applied_targets = list((row.extras or {}).get('targets') or [])
|
|
1584
|
+
extra_full: dict[str, Any] = {}
|
|
1585
|
+
if outcome.recovery_path is not None:
|
|
1586
|
+
extra_full['recovery_path'] = outcome.recovery_path
|
|
1587
|
+
mark_closing(
|
|
1588
|
+
self.store,
|
|
1589
|
+
coid=row.client_order_id,
|
|
1590
|
+
kind=KIND_FULL_CLOSE,
|
|
1591
|
+
targets=applied_targets,
|
|
1592
|
+
extra_payload=extra_full or None,
|
|
1593
|
+
)
|
|
1594
|
+
elif kind == KIND_PARTIAL_CLOSE:
|
|
1595
|
+
extra_partial: dict[str, Any] = {}
|
|
1596
|
+
if outcome.recovery_path is not None:
|
|
1597
|
+
extra_partial['recovery_path'] = outcome.recovery_path
|
|
1598
|
+
mark_close_completed(
|
|
1599
|
+
self.store,
|
|
1600
|
+
coid=row.client_order_id,
|
|
1601
|
+
kind=KIND_PARTIAL_CLOSE,
|
|
1602
|
+
extra_payload=extra_partial or None,
|
|
1603
|
+
)
|
|
1604
|
+
self.store.close_order(row.client_order_id)
|
|
1605
|
+
else:
|
|
1606
|
+
mark_confirmed_with_fill(
|
|
1607
|
+
self.store,
|
|
1608
|
+
coid=row.client_order_id,
|
|
1609
|
+
exchange_id=outcome.exchange_id,
|
|
1610
|
+
is_filled=outcome.is_filled,
|
|
1611
|
+
filled_qty=outcome.filled_qty,
|
|
1612
|
+
fill_price=outcome.fill_price,
|
|
1613
|
+
)
|
|
1614
|
+
payload: dict[str, Any] = {
|
|
1615
|
+
'is_filled': outcome.is_filled,
|
|
1616
|
+
'fill_price': outcome.fill_price,
|
|
1617
|
+
'prior_state': row.state,
|
|
1618
|
+
}
|
|
1619
|
+
_merge_recovery_diagnostics(payload, outcome)
|
|
1620
|
+
self.store.log_event(
|
|
1621
|
+
'recovered_confirmed',
|
|
1622
|
+
client_order_id=row.client_order_id,
|
|
1623
|
+
exchange_order_id=outcome.exchange_id,
|
|
1624
|
+
intent_key=row.intent_key,
|
|
1625
|
+
payload=payload,
|
|
1626
|
+
)
|
|
1627
|
+
elif outcome.status == 'rejected':
|
|
1628
|
+
mark_rejected(self.store, coid=row.client_order_id)
|
|
1629
|
+
payload = {
|
|
1630
|
+
'reason': outcome.reject_reason,
|
|
1631
|
+
'prior_state': row.state,
|
|
1632
|
+
}
|
|
1633
|
+
_merge_recovery_diagnostics(payload, outcome)
|
|
1634
|
+
self.store.log_event(
|
|
1635
|
+
'recovered_rejected',
|
|
1636
|
+
client_order_id=row.client_order_id,
|
|
1637
|
+
intent_key=row.intent_key,
|
|
1638
|
+
payload=payload,
|
|
1639
|
+
)
|
|
1640
|
+
else:
|
|
1641
|
+
# still_unknown — keep the row; engine reconciler retries.
|
|
1642
|
+
payload = {'prior_state': row.state}
|
|
1643
|
+
_merge_recovery_diagnostics(payload, outcome)
|
|
1644
|
+
self.store.log_event(
|
|
1645
|
+
'recovery_pending',
|
|
1646
|
+
client_order_id=row.client_order_id,
|
|
1647
|
+
intent_key=row.intent_key,
|
|
1648
|
+
payload=payload,
|
|
1649
|
+
)
|
|
1650
|
+
return PendingResolution(
|
|
1651
|
+
coid=row.client_order_id,
|
|
1652
|
+
status=outcome.status,
|
|
1653
|
+
reason=outcome.reject_reason,
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
def apply_reconcile_outcome(
|
|
1657
|
+
self,
|
|
1658
|
+
coid: str,
|
|
1659
|
+
outcome: ReconcileOutcome,
|
|
1660
|
+
) -> None:
|
|
1661
|
+
"""Persist a reconcile-path terminal mutation.
|
|
1662
|
+
|
|
1663
|
+
Called once per row that the plugin reconciler decides needs a
|
|
1664
|
+
terminal lifecycle write — working→position fill detection,
|
|
1665
|
+
bracket sibling retire on mixed-bracket rejection, pending-trail
|
|
1666
|
+
parent-rejection cascade, missing-pending grace expiry,
|
|
1667
|
+
unexpected-cancel cascade, eager-teardown follow-up. The plugin
|
|
1668
|
+
decides *what happened* (sibling selection, reason
|
|
1669
|
+
classification, snapshot interpretation); the journal applies
|
|
1670
|
+
the state transition, optionally retires the row from
|
|
1671
|
+
``iter_live_orders``, and writes the audit event.
|
|
1672
|
+
|
|
1673
|
+
Cat 1 reconciler-private breadcrumbs
|
|
1674
|
+
(``missing_pending_since`` / ``close_event_yielded_at`` family)
|
|
1675
|
+
are NOT routed here — those are plugin-namespace
|
|
1676
|
+
``extras`` and stay direct :meth:`RunContext.upsert_order`
|
|
1677
|
+
writes. See :class:`ReconcileOutcome` for the full ownership
|
|
1678
|
+
contract.
|
|
1679
|
+
|
|
1680
|
+
:param coid: The row's client-order-id.
|
|
1681
|
+
:param outcome: The plugin's declared reconcile verdict.
|
|
1682
|
+
:raises ValueError: when ``outcome.kind`` is ``'filled'`` but
|
|
1683
|
+
``outcome.filled_qty`` is ``None``.
|
|
1684
|
+
"""
|
|
1685
|
+
if outcome.kind == 'filled':
|
|
1686
|
+
if outcome.filled_qty is None:
|
|
1687
|
+
raise ValueError(
|
|
1688
|
+
f"ReconcileOutcome kind='filled' requires filled_qty, "
|
|
1689
|
+
f"got None (coid={coid!r}, reason={outcome.reason!r})"
|
|
1690
|
+
)
|
|
1691
|
+
mark_reconcile_filled(
|
|
1692
|
+
self.store,
|
|
1693
|
+
coid=coid,
|
|
1694
|
+
filled_qty=outcome.filled_qty,
|
|
1695
|
+
new_state=outcome.new_state,
|
|
1696
|
+
extras_patch=outcome.extras_patch,
|
|
1697
|
+
)
|
|
1698
|
+
else:
|
|
1699
|
+
mark_reconcile_terminal_close(
|
|
1700
|
+
self.store,
|
|
1701
|
+
coid=coid,
|
|
1702
|
+
new_state=outcome.new_state,
|
|
1703
|
+
extras_patch=outcome.extras_patch,
|
|
1704
|
+
close_row=outcome.close_row,
|
|
1705
|
+
)
|
|
1706
|
+
self.store.log_event(
|
|
1707
|
+
outcome.audit_event,
|
|
1708
|
+
client_order_id=coid,
|
|
1709
|
+
exchange_order_id=outcome.exchange_order_id,
|
|
1710
|
+
payload=(
|
|
1711
|
+
dict(outcome.audit_payload)
|
|
1712
|
+
if outcome.audit_payload is not None else None
|
|
1713
|
+
),
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
# === Recovery support types ================================================
|
|
1718
|
+
|
|
1719
|
+
@dataclass(frozen=True)
|
|
1720
|
+
class PendingResolution:
|
|
1721
|
+
"""One row's recovery outcome, returned for inspection / tests.
|
|
1722
|
+
|
|
1723
|
+
:ivar coid: The recovered row's client-order-id.
|
|
1724
|
+
:ivar status: ``'confirmed'`` / ``'rejected'`` / ``'still_unknown'``
|
|
1725
|
+
(echo of :class:`ResumeOutcome.status`) or ``'skipped'`` when
|
|
1726
|
+
the row's ``kind`` was not handled by this journal.
|
|
1727
|
+
:ivar reason: Free-form note. For ``'rejected'`` carries the
|
|
1728
|
+
plugin's reject reason; for ``'skipped'`` describes why the
|
|
1729
|
+
row was bypassed.
|
|
1730
|
+
"""
|
|
1731
|
+
coid: str
|
|
1732
|
+
status: Literal['confirmed', 'rejected', 'still_unknown', 'skipped']
|
|
1733
|
+
reason: str | None = None
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
class PendingHooksProvider(Protocol):
|
|
1737
|
+
"""Callable that maps a pending row to its recovery hook.
|
|
1738
|
+
|
|
1739
|
+
The plugin implements this to decide which hook flavour applies
|
|
1740
|
+
to each ``extras['kind']``. Returning ``None`` makes the journal
|
|
1741
|
+
record a ``skipped`` resolution for that row (e.g. bracket legs
|
|
1742
|
+
handled by their own resolver).
|
|
1743
|
+
|
|
1744
|
+
The recovery contract only requires ``resume_pending_dispatch``;
|
|
1745
|
+
the duck-typed return is annotated as :class:`EntryDispatchHooks`
|
|
1746
|
+
for IDE convenience, but any hook flavour with a compatible
|
|
1747
|
+
``resume_pending_dispatch`` shape is accepted.
|
|
1748
|
+
"""
|
|
1749
|
+
|
|
1750
|
+
def __call__(self, row: 'OrderRow') -> EntryDispatchHooks | None: ...
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
# === Private helpers =======================================================
|
|
1754
|
+
|
|
1755
|
+
def _merge_recovery_diagnostics(
|
|
1756
|
+
payload: dict[str, Any], outcome: ResumeOutcome,
|
|
1757
|
+
) -> None:
|
|
1758
|
+
"""Inject ``recovery_path`` / ``recovery_context`` into an event payload.
|
|
1759
|
+
|
|
1760
|
+
Both fields are optional plugin-supplied diagnostics; when ``None``
|
|
1761
|
+
they are not written at all so the on-disk audit shape stays
|
|
1762
|
+
minimal for hooks that do not bother annotating the route.
|
|
1763
|
+
"""
|
|
1764
|
+
if outcome.recovery_path is not None:
|
|
1765
|
+
payload['recovery_path'] = outcome.recovery_path
|
|
1766
|
+
if outcome.recovery_context is not None:
|
|
1767
|
+
payload['recovery_context'] = dict(outcome.recovery_context)
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _collect_refs_for(store: 'RunContext', *, coid: str) -> Mapping[str, str]:
|
|
1771
|
+
"""Materialise the full ``order_refs`` map for one COID.
|
|
1772
|
+
|
|
1773
|
+
Required for the narrow crash window inside
|
|
1774
|
+
:func:`~pynecore.core.broker.store_helpers.record_server_ref`:
|
|
1775
|
+
between the ``add_ref('deal_reference', ...)`` commit and the
|
|
1776
|
+
subsequent ``upsert_order(extras={...})`` commit the
|
|
1777
|
+
``deal_reference`` lives *only* in ``order_refs``. A resume hook
|
|
1778
|
+
that relied solely on ``row.extras`` would miss it and treat the
|
|
1779
|
+
already-submitted order as never posted, causing a duplicate POST
|
|
1780
|
+
on the next restart.
|
|
1781
|
+
|
|
1782
|
+
The result is a plain ``dict``; multiple refs of the same type are
|
|
1783
|
+
not expected per COID and the last one wins if they ever occur.
|
|
1784
|
+
"""
|
|
1785
|
+
return dict(store.iter_refs_for_coid(coid))
|