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,927 @@
|
|
|
1
|
+
"""Generic bot-owned-order disappearance tracking for broker plugins.
|
|
2
|
+
|
|
3
|
+
Detecting bot-owned orders that vanish behind the engine's back (manual
|
|
4
|
+
close on the broker UI, broker-side liquidation, silent cancel) is a
|
|
5
|
+
plugin responsibility, but its core state machine is venue-agnostic and
|
|
6
|
+
was implemented twice (Capital.com and cTrader). This module extracts
|
|
7
|
+
that core:
|
|
8
|
+
|
|
9
|
+
- **Persisted stamp / clear / grace state machine.** A row whose broker
|
|
10
|
+
counterpart has vanished from every tracked namespace gets a
|
|
11
|
+
``missing_pending_since`` stamp in its ``extras``; the stamp is cleared
|
|
12
|
+
the moment any tracked ref reappears. Only a stamp older than the grace
|
|
13
|
+
window triggers any irreversible action — a fill in flight can flicker
|
|
14
|
+
out of every snapshot for one poll. Stamp and clear both re-read the
|
|
15
|
+
live row and touch *only* the ``missing_pending_since`` key, so a
|
|
16
|
+
breadcrumb written concurrently by another broker thread is never
|
|
17
|
+
clobbered.
|
|
18
|
+
- **Typed ``(namespace, ref)`` keys.** A venue stores bot orders in
|
|
19
|
+
several resource namespaces (working orders, open positions,
|
|
20
|
+
position-attached brackets); refs are tracked per namespace so an
|
|
21
|
+
order-id can never collide with a position-id.
|
|
22
|
+
- **Per-namespace snapshot completeness.** A namespace whose fetch failed
|
|
23
|
+
this pass is reported as ``None``: rows tracked in it are neither
|
|
24
|
+
stamped nor cleared — an incomplete snapshot must never look like a
|
|
25
|
+
complete absence.
|
|
26
|
+
- **Declarative grace-expiry classification.** At grace expiry the venue
|
|
27
|
+
hook ``confirm_missing`` re-verifies the disappearance (e.g. against
|
|
28
|
+
the deal history) and returns a :class:`MissingConfirmation` — it does
|
|
29
|
+
NOT mutate. The tracker applies the outcome in ONE serialized store
|
|
30
|
+
transaction: discovered fill slice + terminal state + sibling
|
|
31
|
+
retirement together, so a partial-fill-then-cancel can never lose its
|
|
32
|
+
fill slice, and a crash cannot leave a half-applied resolution. Every
|
|
33
|
+
post-apply artefact (the cancelled event, the policy hook, the
|
|
34
|
+
execution registration) is built from the row re-read *after* the fill
|
|
35
|
+
slice was booked, so none of them can carry stale pre-fill quantities.
|
|
36
|
+
- **Stamp-version guard.** The row can come back (or be re-stamped)
|
|
37
|
+
while the async confirmation runs; the apply transaction re-checks the
|
|
38
|
+
original ``missing_pending_since`` value and drops a stale outcome.
|
|
39
|
+
- **Fail-closed on unpriced fills.** A discovered fill slice is only
|
|
40
|
+
booked when it carries a strictly positive price and a strictly
|
|
41
|
+
positive persisted quantity delta. An unpriced fill would be dropped by
|
|
42
|
+
the engine's ``record_fill`` yet still advance the store's
|
|
43
|
+
``filled_qty`` and seed the plugin's dedup channel — so the tracker
|
|
44
|
+
keeps the stamp and defers instead of concluding on unpriced evidence.
|
|
45
|
+
- **Dual signal.** A confirmed unexpected cancel is booked as a terminal
|
|
46
|
+
close AND yielded as a synthesised ``cancelled`` :class:`OrderEvent`
|
|
47
|
+
(the engine's router cleans its tracking — essential under the
|
|
48
|
+
non-halting policies), while the configured ``on_unexpected_cancel``
|
|
49
|
+
policy separately decides the operational reaction. ``stop`` /
|
|
50
|
+
``stop_and_cancel`` QUARANTINE through the ``request_quarantine`` hook:
|
|
51
|
+
trading stops (the engine blocks new / exposure-increasing dispatch)
|
|
52
|
+
but the process — and this tracker's ingestion loop — stays alive;
|
|
53
|
+
raising here instead would tear down the very event stream the
|
|
54
|
+
quarantine invariant requires to keep running. Only the explicit
|
|
55
|
+
``halt`` policy (or a quarantining policy with no hook wired — the
|
|
56
|
+
fail-safe fallback) arms the process-exiting halt. Persistence and
|
|
57
|
+
policy application run BEFORE the event is yielded, so neither depends
|
|
58
|
+
on the consumer pulling another element from the generator; a pending
|
|
59
|
+
halt survives an abandoned generator and re-raises on the next
|
|
60
|
+
:meth:`DisappearanceTracker.observe` call. The halt is delivered
|
|
61
|
+
exactly once — each raise site consumes it.
|
|
62
|
+
"""
|
|
63
|
+
import logging
|
|
64
|
+
import math
|
|
65
|
+
from collections.abc import (
|
|
66
|
+
AsyncIterator,
|
|
67
|
+
Awaitable,
|
|
68
|
+
Callable,
|
|
69
|
+
Iterable,
|
|
70
|
+
Mapping,
|
|
71
|
+
Set,
|
|
72
|
+
)
|
|
73
|
+
from dataclasses import dataclass
|
|
74
|
+
from enum import StrEnum
|
|
75
|
+
from typing import TYPE_CHECKING, Any
|
|
76
|
+
|
|
77
|
+
from pynecore.core.broker.exceptions import UnexpectedCancelError
|
|
78
|
+
from pynecore.core.broker.journal import (
|
|
79
|
+
DispatchJournal,
|
|
80
|
+
ReconcileOutcome,
|
|
81
|
+
ReconcileReason,
|
|
82
|
+
)
|
|
83
|
+
from pynecore.core.broker.models import (
|
|
84
|
+
ExchangeOrder,
|
|
85
|
+
LegType,
|
|
86
|
+
OrderEvent,
|
|
87
|
+
OrderStatus,
|
|
88
|
+
OrderType,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if TYPE_CHECKING:
|
|
92
|
+
from pynecore.core.broker.storage import OrderRow, RunContext
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
'MISSING_PENDING_EXTRA',
|
|
96
|
+
'UNEXPECTED_CANCEL_POLICIES',
|
|
97
|
+
'DisappearanceTracker',
|
|
98
|
+
'MissingConfirmation',
|
|
99
|
+
'MissingResolution',
|
|
100
|
+
'resolve_unexpected_cancel_policy',
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
logger = logging.getLogger(__name__)
|
|
104
|
+
|
|
105
|
+
#: The persisted observation breadcrumb key in ``OrderRow.extras``. The
|
|
106
|
+
#: same key both reference plugins already use, so a refactor onto the
|
|
107
|
+
#: tracker inherits stamps persisted by earlier plugin versions.
|
|
108
|
+
MISSING_PENDING_EXTRA = 'missing_pending_since'
|
|
109
|
+
|
|
110
|
+
#: Valid ``on_unexpected_cancel`` policies (see ``BrokerDefaults``).
|
|
111
|
+
UNEXPECTED_CANCEL_POLICIES = (
|
|
112
|
+
'stop', 'stop_and_cancel', 're_place', 'ignore', 'halt',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
#: Float comparison slack for fill quantities, matching the reconcile
|
|
116
|
+
#: paths in the reference plugins.
|
|
117
|
+
_QTY_EPS = 1e-9
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_unexpected_cancel_policy(
|
|
121
|
+
policy: str,
|
|
122
|
+
*,
|
|
123
|
+
reason: str,
|
|
124
|
+
context: dict[str, Any],
|
|
125
|
+
request_quarantine: Callable[[str, dict[str, Any]], None] | None,
|
|
126
|
+
log_event: Callable[..., None] | None,
|
|
127
|
+
client_order_id: str | None,
|
|
128
|
+
exchange_order_id: str | None,
|
|
129
|
+
) -> UnexpectedCancelError | None:
|
|
130
|
+
"""Resolve the ``on_unexpected_cancel`` policy for a confirmed
|
|
131
|
+
unexpected cancel — the mapping shared by the grace-window tracker path
|
|
132
|
+
(:meth:`DisappearanceTracker._apply_policy`) and the sync engine's
|
|
133
|
+
WS-push handler
|
|
134
|
+
(:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._apply_unexpected_cancel_policy`).
|
|
135
|
+
|
|
136
|
+
Applies the ``ignore`` / ``re_place`` audit-only logging and the
|
|
137
|
+
``stop`` / ``stop_and_cancel`` quarantine latch, and RETURNS the halt
|
|
138
|
+
the caller must arm its own way (the tracker parks it on
|
|
139
|
+
:attr:`pending_halt`; the engine records + raises it) — or ``None``
|
|
140
|
+
when the outcome needed no halt (ignored, re-placed, or quarantined).
|
|
141
|
+
The fail-closed rule lives here: a ``stop`` / ``stop_and_cancel``
|
|
142
|
+
whose ``request_quarantine`` sink is missing or raises falls back to
|
|
143
|
+
the halt, never to continued trading. The caller owns the
|
|
144
|
+
``stop_and_cancel`` sibling sweep — it is async and shaped differently
|
|
145
|
+
per path.
|
|
146
|
+
|
|
147
|
+
:param request_quarantine: Quarantine latch sink; ``None`` (an unwired
|
|
148
|
+
sink under a quarantining policy) triggers the fail-closed halt.
|
|
149
|
+
:param log_event: Audit sink (``RunContext.log_event``); ``None`` skips
|
|
150
|
+
audit persistence (a store-less engine on the push path).
|
|
151
|
+
"""
|
|
152
|
+
def _log(kind: str) -> None:
|
|
153
|
+
if log_event is not None:
|
|
154
|
+
log_event(
|
|
155
|
+
kind,
|
|
156
|
+
client_order_id=client_order_id,
|
|
157
|
+
exchange_order_id=exchange_order_id,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if policy == 'ignore':
|
|
161
|
+
_log('unexpected_cancel_ignored')
|
|
162
|
+
return None
|
|
163
|
+
if policy == 're_place':
|
|
164
|
+
_log('unexpected_cancel_re_place')
|
|
165
|
+
return None
|
|
166
|
+
quarantined = False
|
|
167
|
+
if (policy in ('stop', 'stop_and_cancel')
|
|
168
|
+
and request_quarantine is not None):
|
|
169
|
+
# noinspection PyBroadException
|
|
170
|
+
try:
|
|
171
|
+
request_quarantine(reason, context)
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.exception(
|
|
174
|
+
"request_quarantine hook failed for %r; falling back "
|
|
175
|
+
"to the process-exiting halt", client_order_id,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
quarantined = True
|
|
179
|
+
_log('unexpected_cancel_quarantine')
|
|
180
|
+
if not quarantined:
|
|
181
|
+
# 'halt', or a quarantining policy whose hook is missing / raised:
|
|
182
|
+
# arm the process-exiting manual-intervention signal.
|
|
183
|
+
return UnexpectedCancelError(reason, context=context)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class MissingResolution(StrEnum):
|
|
188
|
+
"""Grace-expiry classification returned by the ``confirm_missing`` hook.
|
|
189
|
+
|
|
190
|
+
- :data:`STILL_PRESENT` — the row's broker counterpart is verifiably
|
|
191
|
+
back (or the earlier absence was a snapshot artifact): clear the
|
|
192
|
+
stamp, no further action.
|
|
193
|
+
- :data:`INCONCLUSIVE` — the verification could not complete (e.g.
|
|
194
|
+
history transport down, pagination hole): keep the stamp and wait;
|
|
195
|
+
never conclude a cancel from missing evidence.
|
|
196
|
+
- :data:`FILLED` — the ref vanished because it (partially or fully)
|
|
197
|
+
filled: the stamp premise is false; any discovered fill slice is
|
|
198
|
+
applied and the row stays live for the venue's normal promotion
|
|
199
|
+
path.
|
|
200
|
+
- :data:`CLOSED` — the row filled and its position was then closed
|
|
201
|
+
(native TP/SL fired, external close): retire the row (and its
|
|
202
|
+
position siblings) as a natural close — never a synthetic cancel,
|
|
203
|
+
and no fill event for exposure that no longer exists.
|
|
204
|
+
- :data:`CANCELLED` — verified external cancel: terminal-close the
|
|
205
|
+
row, emit the synthesised ``cancelled`` event and apply the
|
|
206
|
+
``on_unexpected_cancel`` policy.
|
|
207
|
+
"""
|
|
208
|
+
STILL_PRESENT = 'still_present'
|
|
209
|
+
INCONCLUSIVE = 'inconclusive'
|
|
210
|
+
FILLED = 'filled'
|
|
211
|
+
CLOSED = 'closed'
|
|
212
|
+
CANCELLED = 'cancelled'
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True, slots=True)
|
|
216
|
+
class MissingConfirmation:
|
|
217
|
+
"""Declarative result of a grace-expiry re-verification.
|
|
218
|
+
|
|
219
|
+
The ``confirm_missing`` hook builds this from venue evidence (deal
|
|
220
|
+
history, activity log, order-status probe) and mutates NOTHING — the
|
|
221
|
+
tracker applies the whole outcome in one store transaction so it is
|
|
222
|
+
either fully booked or fully dropped.
|
|
223
|
+
|
|
224
|
+
Any terminal resolution may carry a discovered fill slice: a working
|
|
225
|
+
order that partially filled and was then externally cancelled
|
|
226
|
+
resolves as :data:`MissingResolution.CANCELLED` *with* fill data, and
|
|
227
|
+
the tracker books the slice in the same transaction as the terminal
|
|
228
|
+
close. A fill slice is only booked when ``fill_price`` is strictly
|
|
229
|
+
positive; an unpriced slice makes the tracker defer (keep the stamp)
|
|
230
|
+
rather than book exposure the engine would silently drop.
|
|
231
|
+
|
|
232
|
+
:ivar resolution: The classification — see :class:`MissingResolution`.
|
|
233
|
+
A plain string equal to one of the enum values is coerced.
|
|
234
|
+
:ivar cumulative_filled_qty: The order's proven cumulative filled
|
|
235
|
+
quantity, when the verification discovered fill progress. The
|
|
236
|
+
tracker clamps it into ``[row.filled_qty, row.qty]`` (monotonic,
|
|
237
|
+
never overstating the order's own size) and books the delta.
|
|
238
|
+
:ivar fill_price: Volume-weighted price of the discovered executions
|
|
239
|
+
(the price the delta is booked at). Must be strictly positive for
|
|
240
|
+
the slice to be booked.
|
|
241
|
+
:ivar fill_fee: Summed commission of the discovered executions.
|
|
242
|
+
:ivar execution_ids: Venue execution/deal ids backing the evidence.
|
|
243
|
+
Passed to the ``register_executions`` hook after a fill slice was
|
|
244
|
+
actually booked or the row was retired on a terminal resolution,
|
|
245
|
+
so the plugin can seed its duplicate-fill channel; a lone id is
|
|
246
|
+
also stamped as the fill event's ``fill_id``.
|
|
247
|
+
:ivar position_ref: Venue position id the fill belongs to. Selects
|
|
248
|
+
the ``working_promoted_position`` audit reason and scopes the
|
|
249
|
+
sibling retirement on :data:`MissingResolution.CLOSED`.
|
|
250
|
+
:ivar extras_patch: Plugin extras merged into the row alongside a
|
|
251
|
+
discovered fill (e.g. ``{'position_id': ...}``).
|
|
252
|
+
:ivar executed_ts: Broker-reported execution time (unix seconds) for
|
|
253
|
+
the fill event; falls back to the observation time.
|
|
254
|
+
"""
|
|
255
|
+
resolution: MissingResolution
|
|
256
|
+
cumulative_filled_qty: float | None = None
|
|
257
|
+
fill_price: float | None = None
|
|
258
|
+
fill_fee: float = 0.0
|
|
259
|
+
execution_ids: tuple[str, ...] = ()
|
|
260
|
+
position_ref: str | None = None
|
|
261
|
+
extras_patch: Mapping[str, Any] | None = None
|
|
262
|
+
executed_ts: float | None = None
|
|
263
|
+
|
|
264
|
+
def __post_init__(self) -> None:
|
|
265
|
+
# Coerce a plain string (or reject an invalid value at construction
|
|
266
|
+
# time) so the apply path can rely on identity checks against the
|
|
267
|
+
# enum members and never fall through to the CANCELLED mutation.
|
|
268
|
+
if not isinstance(self.resolution, MissingResolution):
|
|
269
|
+
object.__setattr__(
|
|
270
|
+
self, 'resolution', MissingResolution(self.resolution),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class DisappearanceTracker:
|
|
275
|
+
"""Venue-agnostic disappearance state machine over the BrokerStore.
|
|
276
|
+
|
|
277
|
+
One instance per plugin. The plugin's snapshot loop calls
|
|
278
|
+
:meth:`observe` once per pass with the per-namespace present-ref
|
|
279
|
+
sets; the tracker owns stamping, clearing, grace accounting, the
|
|
280
|
+
grace-expiry confirmation protocol and the dual-signal delivery. All
|
|
281
|
+
venue knowledge enters through the constructor hooks.
|
|
282
|
+
|
|
283
|
+
:param store_ctx: The plugin's open :class:`RunContext`.
|
|
284
|
+
:param grace_s: Seconds a stamp must age before ``confirm_missing``
|
|
285
|
+
runs. Venue-tuned (``max(5, 5 × poll cadence)`` on the reference
|
|
286
|
+
plugins).
|
|
287
|
+
:param policy: The configured ``on_unexpected_cancel`` policy —
|
|
288
|
+
one of :data:`UNEXPECTED_CANCEL_POLICIES`.
|
|
289
|
+
:param tracked_refs: Maps a live row to its ``{(namespace, ref)}``
|
|
290
|
+
set. An empty set exempts the row from tracking (e.g. bracket
|
|
291
|
+
legs with no broker id of their own).
|
|
292
|
+
:param confirm_missing: Async grace-expiry verifier — returns a
|
|
293
|
+
:class:`MissingConfirmation`, mutates nothing.
|
|
294
|
+
:param is_exempt: Optional extra exemption (e.g. rows already flagged
|
|
295
|
+
as naturally closed). Checked on every pass, both phases.
|
|
296
|
+
:param cancel_siblings: Async best-effort cancel sweep over the
|
|
297
|
+
origin row's sibling orders; required by (and only used for) the
|
|
298
|
+
``stop_and_cancel`` policy. Best-effort — a raising sweep is
|
|
299
|
+
logged and does not swallow the armed quarantine / halt.
|
|
300
|
+
:param request_quarantine: Sink that latches the engine's quarantine
|
|
301
|
+
state (``(reason, context)``); the runner wires it to
|
|
302
|
+
``OrderSyncEngine.record_quarantine``. Used by the ``stop`` and
|
|
303
|
+
``stop_and_cancel`` policies. When it is missing (or raises),
|
|
304
|
+
those policies fall back to arming the process-exiting halt —
|
|
305
|
+
fail-safe, never fail-open into continued trading.
|
|
306
|
+
:param sibling_coids: Maps a :data:`MissingResolution.CLOSED` row to
|
|
307
|
+
the client-order-ids of live sibling rows sharing its position
|
|
308
|
+
(a netting account merges pyramid entries onto one position id);
|
|
309
|
+
they are retired in the same transaction.
|
|
310
|
+
:param register_executions: Called with the confirmation's
|
|
311
|
+
``execution_ids`` after a fill slice was actually booked or the
|
|
312
|
+
row was retired on a terminal resolution, so the plugin can seed
|
|
313
|
+
its duplicate-fill channel before any replayed push event. A
|
|
314
|
+
raising hook is logged, not propagated.
|
|
315
|
+
:param cancelled_event_factory: Overrides the synthesised
|
|
316
|
+
``cancelled`` event construction (venues that key the event on
|
|
317
|
+
something other than ``row.exchange_order_id``). The default
|
|
318
|
+
builder is entry-shaped (``LegType.ENTRY``, ``reduce_only=False``,
|
|
319
|
+
``OrderType.MARKET``); a non-entry row MUST supply a custom factory.
|
|
320
|
+
:param fill_event_factory: Overrides the recovered-fill event
|
|
321
|
+
construction. Receives ``(row, confirmation, cumulative,
|
|
322
|
+
fill_qty, now_ts)``. The default builder is entry-shaped as
|
|
323
|
+
above; a non-entry row MUST supply a custom factory.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self,
|
|
328
|
+
store_ctx: 'RunContext',
|
|
329
|
+
*,
|
|
330
|
+
grace_s: float,
|
|
331
|
+
policy: str,
|
|
332
|
+
tracked_refs: Callable[['OrderRow'], Set[tuple[str, str]]],
|
|
333
|
+
confirm_missing: Callable[['OrderRow'], Awaitable[MissingConfirmation]],
|
|
334
|
+
is_exempt: Callable[['OrderRow'], bool] | None = None,
|
|
335
|
+
cancel_siblings: Callable[['OrderRow'], Awaitable[None]] | None = None,
|
|
336
|
+
request_quarantine: Callable[[str, dict[str, Any]], None] | None = None,
|
|
337
|
+
sibling_coids: Callable[
|
|
338
|
+
['OrderRow', MissingConfirmation], Iterable[str]] | None = None,
|
|
339
|
+
register_executions: Callable[
|
|
340
|
+
['OrderRow', tuple[str, ...]], None] | None = None,
|
|
341
|
+
cancelled_event_factory: Callable[
|
|
342
|
+
['OrderRow', float], OrderEvent] | None = None,
|
|
343
|
+
fill_event_factory: Callable[
|
|
344
|
+
['OrderRow', MissingConfirmation, float, float, float],
|
|
345
|
+
OrderEvent] | None = None,
|
|
346
|
+
) -> None:
|
|
347
|
+
if policy not in UNEXPECTED_CANCEL_POLICIES:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"DisappearanceTracker: unknown policy {policy!r}; "
|
|
350
|
+
f"expected one of {UNEXPECTED_CANCEL_POLICIES}"
|
|
351
|
+
)
|
|
352
|
+
if policy == 'stop_and_cancel' and cancel_siblings is None:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"DisappearanceTracker: policy 'stop_and_cancel' requires "
|
|
355
|
+
"a cancel_siblings hook"
|
|
356
|
+
)
|
|
357
|
+
self._store = store_ctx
|
|
358
|
+
self._grace_s = grace_s
|
|
359
|
+
self._policy = policy
|
|
360
|
+
self._tracked_refs = tracked_refs
|
|
361
|
+
self._confirm_missing = confirm_missing
|
|
362
|
+
self._is_exempt = is_exempt
|
|
363
|
+
self._cancel_siblings = cancel_siblings
|
|
364
|
+
self._request_quarantine = request_quarantine
|
|
365
|
+
self._sibling_coids = sibling_coids
|
|
366
|
+
self._register_executions = register_executions
|
|
367
|
+
self._cancelled_event_factory = cancelled_event_factory
|
|
368
|
+
self._fill_event_factory = fill_event_factory
|
|
369
|
+
#: Halt decided by a halting policy but not yet delivered to the
|
|
370
|
+
#: consumer. Survives an abandoned generator: re-raised at the top
|
|
371
|
+
#: of the next :meth:`observe` call. Consumed (cleared) by whichever
|
|
372
|
+
#: raise site delivers it, so it fires exactly once.
|
|
373
|
+
self._pending_halt: UnexpectedCancelError | None = None
|
|
374
|
+
#: ``(coid, event_kind)`` pairs already warned about a deferred
|
|
375
|
+
#: grace re-check, so a sustained anomaly logs once per row per
|
|
376
|
+
#: reason (an inconclusive re-check must not mute a later unpriced
|
|
377
|
+
#: fill on the same row), not once per cadence.
|
|
378
|
+
self._warned_deferred: set[tuple[str, str]] = set()
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def pending_halt(self) -> UnexpectedCancelError | None:
|
|
382
|
+
"""The undelivered halt decided by a halting policy, if any.
|
|
383
|
+
|
|
384
|
+
Observational only — delivery happens by the :class:`UnexpectedCancelError`
|
|
385
|
+
raised from :meth:`observe`, which consumes it. A plugin that both
|
|
386
|
+
consumes the generator and reads this property must not act on the
|
|
387
|
+
property independently, or it would double-handle the same halt.
|
|
388
|
+
"""
|
|
389
|
+
return self._pending_halt
|
|
390
|
+
|
|
391
|
+
def _take_pending_halt(self) -> UnexpectedCancelError | None:
|
|
392
|
+
"""Consume the pending halt so it is delivered exactly once."""
|
|
393
|
+
halt = self._pending_halt
|
|
394
|
+
self._pending_halt = None
|
|
395
|
+
return halt
|
|
396
|
+
|
|
397
|
+
async def observe(
|
|
398
|
+
self,
|
|
399
|
+
present: Mapping[str, Set[str] | None],
|
|
400
|
+
now_ts: float,
|
|
401
|
+
) -> AsyncIterator[OrderEvent]:
|
|
402
|
+
"""Run one observation pass and yield the recovered events.
|
|
403
|
+
|
|
404
|
+
Phase 1 stamps / clears every tracked live row against
|
|
405
|
+
``present``; phase 2 runs the grace-expiry confirmation protocol
|
|
406
|
+
on rows whose stamp has aged past the grace window.
|
|
407
|
+
|
|
408
|
+
:param present: Per-namespace sets of the refs visible in this
|
|
409
|
+
pass's snapshot. ``None`` (or a missing key) marks a
|
|
410
|
+
namespace whose fetch FAILED — rows tracked in it are
|
|
411
|
+
neither stamped nor cleared this pass.
|
|
412
|
+
:param now_ts: The observation timestamp (unix seconds); also
|
|
413
|
+
the value stamped into ``missing_pending_since``.
|
|
414
|
+
:raises UnexpectedCancelError: After the ``cancelled`` event of a
|
|
415
|
+
row whose policy is halting — or immediately, when a halt
|
|
416
|
+
decided on an earlier (abandoned) pass is still undelivered.
|
|
417
|
+
"""
|
|
418
|
+
halt = self._take_pending_halt()
|
|
419
|
+
if halt is not None:
|
|
420
|
+
raise halt
|
|
421
|
+
|
|
422
|
+
self.observe_presence(present, now_ts)
|
|
423
|
+
|
|
424
|
+
for row in list(self._store.iter_live_orders()):
|
|
425
|
+
if self._is_exempt is not None and self._is_exempt(row):
|
|
426
|
+
continue
|
|
427
|
+
extras = row.extras or {}
|
|
428
|
+
since = extras.get(MISSING_PENDING_EXTRA)
|
|
429
|
+
if not isinstance(since, (int, float)):
|
|
430
|
+
continue
|
|
431
|
+
since_ts = float(since)
|
|
432
|
+
if (now_ts - since_ts) < self._grace_s:
|
|
433
|
+
continue
|
|
434
|
+
confirmation = await self._confirm_missing(row)
|
|
435
|
+
events, applied, register_ids, applied_row = self._apply_confirmation(
|
|
436
|
+
row, since_ts, confirmation, now_ts,
|
|
437
|
+
)
|
|
438
|
+
# The post-commit hooks must see the row as it stands after the
|
|
439
|
+
# fill slice was booked — a stale pre-fill row would let a sweep
|
|
440
|
+
# miss the recovered quantity / promotion metadata.
|
|
441
|
+
hook_row = applied_row if applied_row is not None else row
|
|
442
|
+
if applied and register_ids and self._register_executions is not None:
|
|
443
|
+
# noinspection PyBroadException
|
|
444
|
+
try:
|
|
445
|
+
self._register_executions(hook_row, register_ids)
|
|
446
|
+
except Exception:
|
|
447
|
+
# A committed terminal row must never be stranded
|
|
448
|
+
# without its event / halt by a dedup-seeding failure.
|
|
449
|
+
logger.exception(
|
|
450
|
+
"register_executions hook failed for %r",
|
|
451
|
+
row.client_order_id,
|
|
452
|
+
)
|
|
453
|
+
if applied and confirmation.resolution is MissingResolution.CANCELLED:
|
|
454
|
+
await self._apply_policy(hook_row)
|
|
455
|
+
for event in events:
|
|
456
|
+
yield event
|
|
457
|
+
halt = self._take_pending_halt()
|
|
458
|
+
if halt is not None:
|
|
459
|
+
raise halt
|
|
460
|
+
|
|
461
|
+
# --- Phase 1: stamp / clear against the present-sets --------------------
|
|
462
|
+
|
|
463
|
+
def observe_presence(
|
|
464
|
+
self,
|
|
465
|
+
present: Mapping[str, Set[str] | None],
|
|
466
|
+
now_ts: float,
|
|
467
|
+
) -> None:
|
|
468
|
+
"""Run phase 1 only: stamp / clear every tracked live row.
|
|
469
|
+
|
|
470
|
+
For venues whose snapshot-reconcile pass owns the presence diff
|
|
471
|
+
(stamping from inside the same walk that books fills) while a
|
|
472
|
+
separate later pass drives the grace protocol via
|
|
473
|
+
:meth:`observe`. :meth:`observe` runs this itself, so calling
|
|
474
|
+
both against the same snapshot is safe — stamp and clear are
|
|
475
|
+
idempotent per pass.
|
|
476
|
+
"""
|
|
477
|
+
live_coids: set[str] = set()
|
|
478
|
+
for row in list(self._store.iter_live_orders()):
|
|
479
|
+
live_coids.add(row.client_order_id)
|
|
480
|
+
if self._is_exempt is not None and self._is_exempt(row):
|
|
481
|
+
continue
|
|
482
|
+
self._observe_presence(row, present, now_ts)
|
|
483
|
+
|
|
484
|
+
# Drop throttle keys for rows that went terminal via another path
|
|
485
|
+
# (a PUSH event) and left the live set without reaching a resolve —
|
|
486
|
+
# else the in-memory set grows unbounded on a long-running instance.
|
|
487
|
+
if self._warned_deferred:
|
|
488
|
+
self._warned_deferred = {
|
|
489
|
+
k for k in self._warned_deferred if k[0] in live_coids
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
def _observe_presence(
|
|
493
|
+
self,
|
|
494
|
+
row: 'OrderRow',
|
|
495
|
+
present: Mapping[str, Set[str] | None],
|
|
496
|
+
now_ts: float,
|
|
497
|
+
) -> None:
|
|
498
|
+
refs = self._tracked_refs(row)
|
|
499
|
+
if not refs:
|
|
500
|
+
return
|
|
501
|
+
visible = False
|
|
502
|
+
all_fetched = True
|
|
503
|
+
for namespace, ref in refs:
|
|
504
|
+
ns_refs = present.get(namespace)
|
|
505
|
+
if ns_refs is None:
|
|
506
|
+
all_fetched = False
|
|
507
|
+
continue
|
|
508
|
+
if ref in ns_refs:
|
|
509
|
+
visible = True
|
|
510
|
+
break
|
|
511
|
+
extras = row.extras or {}
|
|
512
|
+
if visible:
|
|
513
|
+
if MISSING_PENDING_EXTRA in extras:
|
|
514
|
+
self._clear_stamp(row.client_order_id)
|
|
515
|
+
return
|
|
516
|
+
if not all_fetched:
|
|
517
|
+
# Incomplete snapshot: absence is unproven — neither stamp
|
|
518
|
+
# nor clear.
|
|
519
|
+
return
|
|
520
|
+
if MISSING_PENDING_EXTRA not in extras:
|
|
521
|
+
self._stamp(row.client_order_id, now_ts)
|
|
522
|
+
|
|
523
|
+
def _stamp(self, coid: str, now_ts: float) -> None:
|
|
524
|
+
"""Stamp ``missing_pending_since`` atomically, preserving extras.
|
|
525
|
+
|
|
526
|
+
Re-reads the live row and rewrites only the one key so a breadcrumb
|
|
527
|
+
another broker thread wrote between the phase-1 snapshot and here is
|
|
528
|
+
not clobbered; a stamp added concurrently is left untouched.
|
|
529
|
+
"""
|
|
530
|
+
with self._store.transaction():
|
|
531
|
+
fresh = self._store.get_order(coid)
|
|
532
|
+
if fresh is None or fresh.closed_ts_ms is not None:
|
|
533
|
+
return
|
|
534
|
+
extras = dict(fresh.extras or {})
|
|
535
|
+
if MISSING_PENDING_EXTRA in extras:
|
|
536
|
+
return
|
|
537
|
+
extras[MISSING_PENDING_EXTRA] = now_ts
|
|
538
|
+
self._store.upsert_order(coid, extras=extras)
|
|
539
|
+
|
|
540
|
+
def _clear_stamp(self, coid: str) -> None:
|
|
541
|
+
"""Remove ``missing_pending_since`` atomically, preserving extras."""
|
|
542
|
+
with self._store.transaction():
|
|
543
|
+
fresh = self._store.get_order(coid)
|
|
544
|
+
if fresh is not None:
|
|
545
|
+
extras = fresh.extras or {}
|
|
546
|
+
if MISSING_PENDING_EXTRA in extras:
|
|
547
|
+
merged = {k: v for k, v in extras.items()
|
|
548
|
+
if k != MISSING_PENDING_EXTRA}
|
|
549
|
+
self._store.upsert_order(coid, extras=merged)
|
|
550
|
+
self._forget_deferred(coid)
|
|
551
|
+
|
|
552
|
+
# --- Phase 2: grace-expiry confirmation apply ----------------------------
|
|
553
|
+
|
|
554
|
+
def _apply_confirmation(
|
|
555
|
+
self,
|
|
556
|
+
row: 'OrderRow',
|
|
557
|
+
since: float,
|
|
558
|
+
confirmation: MissingConfirmation,
|
|
559
|
+
now_ts: float,
|
|
560
|
+
) -> tuple[list[OrderEvent], bool, tuple[str, ...], 'OrderRow | None']:
|
|
561
|
+
"""Apply one confirmation atomically under the stamp-version guard.
|
|
562
|
+
|
|
563
|
+
Returns ``(events, applied, register_ids, applied_row)``. ``applied``
|
|
564
|
+
is ``False`` when the guard dropped a stale outcome (the row came
|
|
565
|
+
back, was re-stamped or reached a terminal state while the
|
|
566
|
+
confirmation ran) or when a fill slice was deferred for lack of a
|
|
567
|
+
price. ``register_ids`` is non-empty only when a fill slice was
|
|
568
|
+
actually booked OR the row reached a terminal resolution (CLOSED /
|
|
569
|
+
CANCELLED) in the same transaction — the retirement was concluded
|
|
570
|
+
FROM that execution evidence, so a replayed push copy of it must
|
|
571
|
+
already be suppressed. A deferred or dropped outcome never seeds
|
|
572
|
+
the dedup channel: the evidence may still need to book later.
|
|
573
|
+
``applied_row`` is the row re-read *after* the fill slice was
|
|
574
|
+
booked (``None`` on a dropped or no-op outcome); the caller feeds
|
|
575
|
+
it to the post-commit hooks so a sweep never sees a stale pre-fill
|
|
576
|
+
quantity.
|
|
577
|
+
"""
|
|
578
|
+
resolution = confirmation.resolution
|
|
579
|
+
events: list[OrderEvent] = []
|
|
580
|
+
with self._store.transaction():
|
|
581
|
+
fresh = self._store.get_order(row.client_order_id)
|
|
582
|
+
if fresh is None or fresh.closed_ts_ms is not None:
|
|
583
|
+
return [], False, (), None
|
|
584
|
+
stamp = (fresh.extras or {}).get(MISSING_PENDING_EXTRA)
|
|
585
|
+
if stamp != since:
|
|
586
|
+
logger.info(
|
|
587
|
+
"disappearance confirmation for %r dropped: stamp "
|
|
588
|
+
"changed during verification (%r -> %r)",
|
|
589
|
+
row.client_order_id, since, stamp,
|
|
590
|
+
)
|
|
591
|
+
return [], False, (), None
|
|
592
|
+
|
|
593
|
+
if resolution is MissingResolution.INCONCLUSIVE:
|
|
594
|
+
self._warn_deferred(
|
|
595
|
+
fresh, 'missing_pending_recheck_inconclusive',
|
|
596
|
+
"grace-expired row %r left un-retired: disappearance "
|
|
597
|
+
"re-check inconclusive — deferring rather than "
|
|
598
|
+
"concluding a false cancel",
|
|
599
|
+
)
|
|
600
|
+
return [], True, (), None
|
|
601
|
+
|
|
602
|
+
if resolution is MissingResolution.STILL_PRESENT:
|
|
603
|
+
self._clear_stamp(fresh.client_order_id)
|
|
604
|
+
return [], True, (), None
|
|
605
|
+
|
|
606
|
+
# Discovered fill slice (pure computation, no writes yet).
|
|
607
|
+
cumulative = self._clamped_cumulative(fresh, confirmation)
|
|
608
|
+
new_qty = 0.0 if cumulative is None else cumulative - fresh.filled_qty
|
|
609
|
+
has_new_fill = new_qty > _QTY_EPS
|
|
610
|
+
if has_new_fill and not (
|
|
611
|
+
confirmation.fill_price is not None
|
|
612
|
+
and math.isfinite(confirmation.fill_price)
|
|
613
|
+
and confirmation.fill_price > 0.0):
|
|
614
|
+
# Fail-closed: a fill we cannot price (missing, non-finite,
|
|
615
|
+
# or non-positive) would be dropped by the engine's
|
|
616
|
+
# record_fill yet still advance the store and seed dedup.
|
|
617
|
+
# Keep the stamp and defer.
|
|
618
|
+
self._warn_deferred(
|
|
619
|
+
fresh, 'missing_pending_fill_unpriced',
|
|
620
|
+
"grace-expired row %r reported fill progress without a "
|
|
621
|
+
"usable price — deferring rather than booking an "
|
|
622
|
+
"unpriced fill",
|
|
623
|
+
)
|
|
624
|
+
return [], False, (), None
|
|
625
|
+
|
|
626
|
+
register_ids: tuple[str, ...] = ()
|
|
627
|
+
fill_event: OrderEvent | None = None
|
|
628
|
+
updated = fresh
|
|
629
|
+
if has_new_fill and cumulative is not None:
|
|
630
|
+
self._book_fill(fresh, confirmation, cumulative)
|
|
631
|
+
updated = self._store.get_order(fresh.client_order_id) or fresh
|
|
632
|
+
fill_event = self._build_fill_event(
|
|
633
|
+
updated, confirmation, cumulative, new_qty, now_ts,
|
|
634
|
+
)
|
|
635
|
+
register_ids = confirmation.execution_ids
|
|
636
|
+
elif confirmation.position_ref is not None and cumulative is not None:
|
|
637
|
+
# Working->position promotion without fresh quantity (the
|
|
638
|
+
# fill was already booked): flip state + extras, emit and
|
|
639
|
+
# register nothing.
|
|
640
|
+
self._book_fill(fresh, confirmation, cumulative)
|
|
641
|
+
updated = self._store.get_order(fresh.client_order_id) or fresh
|
|
642
|
+
elif confirmation.extras_patch:
|
|
643
|
+
# Metadata-only patch: merge extras WITHOUT manufacturing a
|
|
644
|
+
# kind='filled' journal outcome for a zero-delta write.
|
|
645
|
+
merged = dict(fresh.extras or {})
|
|
646
|
+
merged.update(confirmation.extras_patch)
|
|
647
|
+
self._store.upsert_order(fresh.client_order_id, extras=merged)
|
|
648
|
+
updated = self._store.get_order(fresh.client_order_id) or fresh
|
|
649
|
+
|
|
650
|
+
if resolution is MissingResolution.FILLED:
|
|
651
|
+
# The stamp premise is false — the ref vanished because it
|
|
652
|
+
# filled. The row stays live; the venue's normal snapshot
|
|
653
|
+
# promotion path owns it from here.
|
|
654
|
+
self._clear_stamp(updated.client_order_id)
|
|
655
|
+
if fill_event is not None:
|
|
656
|
+
events.append(fill_event)
|
|
657
|
+
return events, True, register_ids, updated
|
|
658
|
+
|
|
659
|
+
if resolution is MissingResolution.CLOSED:
|
|
660
|
+
# Filled-then-closed: the exposure no longer exists, so no
|
|
661
|
+
# fill event is emitted for it (the engine's position
|
|
662
|
+
# reconcile owns the size side) — but any discovered fill
|
|
663
|
+
# progress was persisted above for bookkeeping. The
|
|
664
|
+
# retirement is concluded FROM the confirmation's execution
|
|
665
|
+
# evidence, so its ids are registered even without a fresh
|
|
666
|
+
# booked slice — a replayed push copy of an already-counted
|
|
667
|
+
# deal must not resurface as a new fill after the retire.
|
|
668
|
+
register_ids = confirmation.execution_ids
|
|
669
|
+
DispatchJournal(self._store).apply_reconcile_outcome(
|
|
670
|
+
updated.client_order_id,
|
|
671
|
+
ReconcileOutcome(
|
|
672
|
+
kind='terminal_close',
|
|
673
|
+
reason='bracket_natural_close_followup',
|
|
674
|
+
new_state='closed',
|
|
675
|
+
audit_event='reconcile_filled_then_closed_retired',
|
|
676
|
+
close_row=True,
|
|
677
|
+
audit_payload={
|
|
678
|
+
'position_ref': confirmation.position_ref,
|
|
679
|
+
'missing_since': since,
|
|
680
|
+
'execution_ids': list(confirmation.execution_ids),
|
|
681
|
+
},
|
|
682
|
+
exchange_order_id=(confirmation.position_ref
|
|
683
|
+
or updated.exchange_order_id),
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
if self._sibling_coids is not None:
|
|
687
|
+
for sibling in self._sibling_coids(updated, confirmation):
|
|
688
|
+
self._store.close_order(sibling)
|
|
689
|
+
self._forget_deferred(updated.client_order_id)
|
|
690
|
+
return [], True, register_ids, updated
|
|
691
|
+
|
|
692
|
+
if resolution is MissingResolution.CANCELLED:
|
|
693
|
+
# Terminal like CLOSED above: register the backing ids so
|
|
694
|
+
# a replayed copy of the evidence cannot re-book after the
|
|
695
|
+
# retire.
|
|
696
|
+
register_ids = confirmation.execution_ids
|
|
697
|
+
DispatchJournal(self._store).apply_reconcile_outcome(
|
|
698
|
+
updated.client_order_id,
|
|
699
|
+
ReconcileOutcome(
|
|
700
|
+
kind='terminal_close',
|
|
701
|
+
reason='missing_pending_grace_expired',
|
|
702
|
+
new_state='rejected',
|
|
703
|
+
audit_event='unexpected_cancel',
|
|
704
|
+
close_row=True,
|
|
705
|
+
audit_payload={'missing_since': since,
|
|
706
|
+
'grace': self._grace_s},
|
|
707
|
+
exchange_order_id=updated.exchange_order_id,
|
|
708
|
+
),
|
|
709
|
+
)
|
|
710
|
+
if fill_event is not None:
|
|
711
|
+
events.append(fill_event)
|
|
712
|
+
events.append(self._build_cancelled_event(updated, now_ts))
|
|
713
|
+
self._forget_deferred(updated.client_order_id)
|
|
714
|
+
return events, True, register_ids, updated
|
|
715
|
+
|
|
716
|
+
raise AssertionError(f"unhandled resolution {resolution!r}")
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def _clamped_cumulative(
|
|
720
|
+
fresh: 'OrderRow', confirmation: MissingConfirmation,
|
|
721
|
+
) -> float | None:
|
|
722
|
+
"""Clamp the confirmed cumulative into ``[fresh.filled_qty, fresh.qty]``.
|
|
723
|
+
|
|
724
|
+
Monotonic (never regresses below what is already booked) and never
|
|
725
|
+
overstates the order's own size. ``None`` when the confirmation
|
|
726
|
+
carried no fill quantity.
|
|
727
|
+
"""
|
|
728
|
+
if confirmation.cumulative_filled_qty is None:
|
|
729
|
+
return None
|
|
730
|
+
return min(
|
|
731
|
+
fresh.qty,
|
|
732
|
+
max(fresh.filled_qty, confirmation.cumulative_filled_qty),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _book_fill(
|
|
736
|
+
self,
|
|
737
|
+
fresh: 'OrderRow',
|
|
738
|
+
confirmation: MissingConfirmation,
|
|
739
|
+
cumulative: float,
|
|
740
|
+
) -> None:
|
|
741
|
+
"""Persist the discovered fill slice inside the caller's transaction."""
|
|
742
|
+
reason: ReconcileReason = (
|
|
743
|
+
'working_promoted_position'
|
|
744
|
+
if confirmation.position_ref is not None
|
|
745
|
+
else 'partial_fill_progress'
|
|
746
|
+
)
|
|
747
|
+
DispatchJournal(self._store).apply_reconcile_outcome(
|
|
748
|
+
fresh.client_order_id,
|
|
749
|
+
ReconcileOutcome(
|
|
750
|
+
kind='filled',
|
|
751
|
+
reason=reason,
|
|
752
|
+
new_state='confirmed',
|
|
753
|
+
audit_event='reconcile_missing_fill_recovered',
|
|
754
|
+
filled_qty=cumulative,
|
|
755
|
+
extras_patch=confirmation.extras_patch,
|
|
756
|
+
audit_payload={
|
|
757
|
+
'cumulative': cumulative,
|
|
758
|
+
'previous': fresh.filled_qty,
|
|
759
|
+
'execution_ids': list(confirmation.execution_ids),
|
|
760
|
+
},
|
|
761
|
+
exchange_order_id=(confirmation.position_ref
|
|
762
|
+
or fresh.exchange_order_id),
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
def _build_fill_event(
|
|
767
|
+
self,
|
|
768
|
+
updated: 'OrderRow',
|
|
769
|
+
confirmation: MissingConfirmation,
|
|
770
|
+
cumulative: float,
|
|
771
|
+
fill_qty: float,
|
|
772
|
+
now_ts: float,
|
|
773
|
+
) -> OrderEvent:
|
|
774
|
+
if self._fill_event_factory is not None:
|
|
775
|
+
return self._fill_event_factory(
|
|
776
|
+
updated, confirmation, cumulative, fill_qty, now_ts,
|
|
777
|
+
)
|
|
778
|
+
return self._default_fill_event(
|
|
779
|
+
updated, confirmation, cumulative, fill_qty, now_ts,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
def _warn_deferred(self, row: 'OrderRow', event_kind: str, msg: str) -> None:
|
|
783
|
+
"""Log once per row per reason that a grace-expired retire deferred."""
|
|
784
|
+
coid = row.client_order_id
|
|
785
|
+
key = (coid, event_kind)
|
|
786
|
+
if key in self._warned_deferred:
|
|
787
|
+
return
|
|
788
|
+
logger.warning(msg, coid)
|
|
789
|
+
self._store.log_event(
|
|
790
|
+
event_kind,
|
|
791
|
+
client_order_id=coid,
|
|
792
|
+
exchange_order_id=row.exchange_order_id,
|
|
793
|
+
)
|
|
794
|
+
# Arm the throttle only after the audit event is persisted, so a
|
|
795
|
+
# failed log_event does not mute a later genuine re-warning.
|
|
796
|
+
self._warned_deferred.add(key)
|
|
797
|
+
|
|
798
|
+
def _forget_deferred(self, coid: str) -> None:
|
|
799
|
+
"""Drop every deferred-warning throttle for a row (it resolved)."""
|
|
800
|
+
self._warned_deferred = {
|
|
801
|
+
k for k in self._warned_deferred if k[0] != coid
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
# --- Policy ---------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
async def _apply_policy(self, row: 'OrderRow') -> None:
|
|
807
|
+
"""Apply the ``on_unexpected_cancel`` policy for a confirmed cancel.
|
|
808
|
+
|
|
809
|
+
Runs BEFORE the cancelled event is yielded: the sweep and the
|
|
810
|
+
quarantine / halt decision must not depend on the consumer pulling
|
|
811
|
+
further elements. ``stop`` and ``stop_and_cancel`` latch the
|
|
812
|
+
engine's quarantine through the ``request_quarantine`` hook — the
|
|
813
|
+
process (and this observation loop) keeps running; a missing or
|
|
814
|
+
raising hook falls back to arming the halt, never to continued
|
|
815
|
+
trading. ``halt`` always arms :attr:`pending_halt`. Either signal
|
|
816
|
+
is armed BEFORE the best-effort sweep, so a raising sweep can
|
|
817
|
+
never swallow it; a pending halt is raised after the row's events
|
|
818
|
+
were yielded (or at the top of the next pass, when the generator
|
|
819
|
+
was abandoned).
|
|
820
|
+
"""
|
|
821
|
+
reason = (
|
|
822
|
+
f"Bot-owned order disappeared unexpectedly: "
|
|
823
|
+
f"coid={row.client_order_id!r} "
|
|
824
|
+
f"ref={row.exchange_order_id!r}"
|
|
825
|
+
)
|
|
826
|
+
context = {
|
|
827
|
+
'client_order_id': row.client_order_id,
|
|
828
|
+
'exchange_order_id': row.exchange_order_id,
|
|
829
|
+
'symbol': row.symbol,
|
|
830
|
+
'policy': self._policy,
|
|
831
|
+
}
|
|
832
|
+
halt = resolve_unexpected_cancel_policy(
|
|
833
|
+
self._policy,
|
|
834
|
+
reason=reason,
|
|
835
|
+
context=context,
|
|
836
|
+
request_quarantine=self._request_quarantine,
|
|
837
|
+
log_event=self._store.log_event,
|
|
838
|
+
client_order_id=row.client_order_id,
|
|
839
|
+
exchange_order_id=row.exchange_order_id,
|
|
840
|
+
)
|
|
841
|
+
if halt is not None:
|
|
842
|
+
self._pending_halt = halt
|
|
843
|
+
if self._policy == 'stop_and_cancel' and self._cancel_siblings is not None:
|
|
844
|
+
# noinspection PyBroadException
|
|
845
|
+
try:
|
|
846
|
+
await self._cancel_siblings(row)
|
|
847
|
+
except Exception:
|
|
848
|
+
logger.exception(
|
|
849
|
+
"cancel_siblings sweep failed for %r; quarantine/halt "
|
|
850
|
+
"stays armed",
|
|
851
|
+
row.client_order_id,
|
|
852
|
+
)
|
|
853
|
+
self._store.log_event(
|
|
854
|
+
'unexpected_cancel_sweep_failed',
|
|
855
|
+
client_order_id=row.client_order_id,
|
|
856
|
+
exchange_order_id=row.exchange_order_id,
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# --- Default event builders ------------------------------------------------
|
|
860
|
+
|
|
861
|
+
def _build_cancelled_event(
|
|
862
|
+
self, row: 'OrderRow', now_ts: float,
|
|
863
|
+
) -> OrderEvent:
|
|
864
|
+
if self._cancelled_event_factory is not None:
|
|
865
|
+
event = self._cancelled_event_factory(row, now_ts)
|
|
866
|
+
else:
|
|
867
|
+
event = OrderEvent(
|
|
868
|
+
order=ExchangeOrder(
|
|
869
|
+
id=row.exchange_order_id or '', symbol=row.symbol,
|
|
870
|
+
side=row.side, order_type=OrderType.MARKET,
|
|
871
|
+
qty=row.qty, filled_qty=row.filled_qty,
|
|
872
|
+
remaining_qty=max(0.0, row.qty - row.filled_qty),
|
|
873
|
+
price=None, stop_price=None, average_fill_price=None,
|
|
874
|
+
status=OrderStatus.CANCELLED, timestamp=now_ts, fee=0.0,
|
|
875
|
+
fee_currency='', reduce_only=False,
|
|
876
|
+
client_order_id=row.client_order_id,
|
|
877
|
+
),
|
|
878
|
+
event_type='cancelled',
|
|
879
|
+
fill_price=None, fill_qty=None, timestamp=now_ts,
|
|
880
|
+
pine_id=row.pine_entry_id, from_entry=row.from_entry,
|
|
881
|
+
leg_type=LegType.ENTRY,
|
|
882
|
+
)
|
|
883
|
+
# Stamp the tracker-origin marker on both the default and the
|
|
884
|
+
# plugin-supplied event: the policy already ran in
|
|
885
|
+
# :meth:`_apply_policy` before this event is emitted, so the sync
|
|
886
|
+
# engine's WS-push handler must NOT re-apply it.
|
|
887
|
+
event.from_disappearance_tracker = True
|
|
888
|
+
return event
|
|
889
|
+
|
|
890
|
+
@staticmethod
|
|
891
|
+
def _default_fill_event(
|
|
892
|
+
row: 'OrderRow',
|
|
893
|
+
confirmation: MissingConfirmation,
|
|
894
|
+
cumulative: float,
|
|
895
|
+
fill_qty: float,
|
|
896
|
+
now_ts: float,
|
|
897
|
+
) -> OrderEvent:
|
|
898
|
+
full = cumulative >= row.qty - _QTY_EPS
|
|
899
|
+
timestamp = confirmation.executed_ts or now_ts
|
|
900
|
+
# A lone backing execution id is a stable duplicate-fill key; an
|
|
901
|
+
# aggregate over several executions has none — the plugin's own
|
|
902
|
+
# dedup channel (via ``register_executions``) must cover it.
|
|
903
|
+
fill_id = (confirmation.execution_ids[0]
|
|
904
|
+
if len(confirmation.execution_ids) == 1 else None)
|
|
905
|
+
return OrderEvent(
|
|
906
|
+
order=ExchangeOrder(
|
|
907
|
+
id=(confirmation.position_ref
|
|
908
|
+
or row.exchange_order_id or ''),
|
|
909
|
+
symbol=row.symbol, side=row.side,
|
|
910
|
+
order_type=OrderType.MARKET,
|
|
911
|
+
qty=row.qty, filled_qty=cumulative,
|
|
912
|
+
remaining_qty=max(0.0, row.qty - cumulative),
|
|
913
|
+
price=None, stop_price=None,
|
|
914
|
+
average_fill_price=confirmation.fill_price,
|
|
915
|
+
status=(OrderStatus.FILLED if full
|
|
916
|
+
else OrderStatus.PARTIALLY_FILLED),
|
|
917
|
+
timestamp=timestamp, fee=confirmation.fill_fee,
|
|
918
|
+
fee_currency='', reduce_only=False,
|
|
919
|
+
client_order_id=row.client_order_id,
|
|
920
|
+
),
|
|
921
|
+
event_type='filled' if full else 'partial',
|
|
922
|
+
fill_price=confirmation.fill_price, fill_qty=fill_qty,
|
|
923
|
+
timestamp=timestamp,
|
|
924
|
+
pine_id=row.pine_entry_id, from_entry=row.from_entry,
|
|
925
|
+
leg_type=LegType.ENTRY, fee=confirmation.fill_fee,
|
|
926
|
+
fill_id=fill_id,
|
|
927
|
+
)
|