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,2161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed helpers over :class:`~pynecore.core.broker.storage.RunContext`.
|
|
3
|
+
|
|
4
|
+
The :class:`BrokerStore` exposes :meth:`upsert_order` with a free-form
|
|
5
|
+
``extras`` dict and individual setters per column. Each broker plugin
|
|
6
|
+
currently re-implements the same micro-patterns on top of that surface:
|
|
7
|
+
seed a row with ``{'kind': ..., 'order_type': ...}`` extras, record the
|
|
8
|
+
server reference, lift the state machine to ``'confirmed'`` with the
|
|
9
|
+
fill price persisted under ``extras['confirm_level']``. The keys are
|
|
10
|
+
implicit, the ordering of writes is implicit, and a typo causes silent
|
|
11
|
+
divergence between Pine intent and broker truth.
|
|
12
|
+
|
|
13
|
+
These helpers turn the most common ``execute_entry`` lifecycle steps
|
|
14
|
+
into typed function calls. They are intentionally a *thin* layer:
|
|
15
|
+
nothing in this module talks to the exchange, retries, sleeps, or makes
|
|
16
|
+
state-machine decisions beyond what the orchestrating
|
|
17
|
+
:class:`~pynecore.core.broker.journal.DispatchJournal` instructs. The
|
|
18
|
+
module is the single place where the canonical ``extras`` schema for
|
|
19
|
+
non-bracket entries lives.
|
|
20
|
+
|
|
21
|
+
Scope is intentionally limited to the M1 proof-of-shape — entry orders
|
|
22
|
+
only, no bracket legs, no trail state, no natural-close flags. Those
|
|
23
|
+
land in later milestones once a second broker plugin confirms the
|
|
24
|
+
shape.
|
|
25
|
+
|
|
26
|
+
See ``docs/pynecore/plugin-system/broker/broker-plugin-responsibility-review.md``
|
|
27
|
+
section §4 for the rationale.
|
|
28
|
+
"""
|
|
29
|
+
from collections.abc import Iterator, Mapping
|
|
30
|
+
from typing import Any, TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from pynecore.core.broker.storage import OrderRow, RunContext
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
'ENTRY_KIND_POSITION',
|
|
37
|
+
'ENTRY_KIND_WORKING',
|
|
38
|
+
'KIND_FULL_CLOSE',
|
|
39
|
+
'KIND_PARTIAL_CLOSE',
|
|
40
|
+
'KIND_CANCEL',
|
|
41
|
+
'KIND_MODIFY_ENTRY',
|
|
42
|
+
'KIND_MODIFY_EXIT',
|
|
43
|
+
'CLOSE_KINDS',
|
|
44
|
+
'STATE_SUBMITTED',
|
|
45
|
+
'STATE_SERVER_REF_SEEN',
|
|
46
|
+
'STATE_CONFIRMED',
|
|
47
|
+
'STATE_REJECTED',
|
|
48
|
+
'STATE_DISPOSITION_UNKNOWN',
|
|
49
|
+
'STATE_CLOSING',
|
|
50
|
+
'STATE_CANCEL_PENDING',
|
|
51
|
+
'STATE_PARTIAL_BRACKET_LEG',
|
|
52
|
+
'PENDING_DISPATCH_STATES',
|
|
53
|
+
'LEG_KIND_TP_PARTIAL',
|
|
54
|
+
'LEG_KIND_SL_PARTIAL',
|
|
55
|
+
'LEG_KIND_TRAIL_PARTIAL',
|
|
56
|
+
'ENGINE_TRIGGER_LEG_KINDS',
|
|
57
|
+
'LEG_STATE_ARMED',
|
|
58
|
+
'LEG_STATE_PENDING_ENTRY',
|
|
59
|
+
'LEG_STATE_TRIGGERING',
|
|
60
|
+
'LEG_STATE_TRIGGERED',
|
|
61
|
+
'LEG_STATE_TRIGGERED_FAILED',
|
|
62
|
+
'LEG_STATE_TRIGGERED_UNKNOWN',
|
|
63
|
+
'LEG_STATE_ABORTED_PARENT_GONE',
|
|
64
|
+
'LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED',
|
|
65
|
+
'LEG_STATE_CASCADED_CANCEL',
|
|
66
|
+
'LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE',
|
|
67
|
+
'LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL',
|
|
68
|
+
'LEG_STATE_CANCEL_TENTATIVE',
|
|
69
|
+
'LEG_STATE_TERMINAL',
|
|
70
|
+
'LEG_STATE_ACTIVE',
|
|
71
|
+
'LEG_STATE_LIVE',
|
|
72
|
+
'EXTRAS_KEY_LEG_KIND',
|
|
73
|
+
'EXTRAS_KEY_LEG_STATE',
|
|
74
|
+
'EXTRAS_KEY_TRIGGER_LEVEL',
|
|
75
|
+
'EXTRAS_KEY_TRIGGER_OFFSET',
|
|
76
|
+
'EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL',
|
|
77
|
+
'EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET',
|
|
78
|
+
'EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF',
|
|
79
|
+
'EXTRAS_KEY_INTENT_PARTIAL_QTY',
|
|
80
|
+
'EXTRAS_KEY_PARENT_PINE_ENTRY_ID',
|
|
81
|
+
'EXTRAS_KEY_OCA_GROUP',
|
|
82
|
+
'EXTRAS_KEY_OCA_TYPE',
|
|
83
|
+
'EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS',
|
|
84
|
+
'create_entry_order_row',
|
|
85
|
+
'record_server_ref',
|
|
86
|
+
'mark_confirmed_with_fill',
|
|
87
|
+
'mark_disposition_unknown',
|
|
88
|
+
'mark_rejected',
|
|
89
|
+
'find_pending_dispatch',
|
|
90
|
+
'create_close_target_row',
|
|
91
|
+
'record_close_server_ref',
|
|
92
|
+
'mark_closing',
|
|
93
|
+
'mark_close_completed',
|
|
94
|
+
'create_cancel_command_row',
|
|
95
|
+
'mark_cancel_completed',
|
|
96
|
+
'create_modify_entry_row',
|
|
97
|
+
'create_modify_exit_row',
|
|
98
|
+
'mark_modify_completed',
|
|
99
|
+
'mark_reconcile_filled',
|
|
100
|
+
'mark_reconcile_terminal_close',
|
|
101
|
+
'create_engine_trigger_partial_leg_row',
|
|
102
|
+
'update_engine_trigger_partial_leg_state',
|
|
103
|
+
'iter_active_engine_trigger_partial_legs',
|
|
104
|
+
'STATE_CLOSE_LEG',
|
|
105
|
+
'CLOSE_LEG_STATE_PENDING',
|
|
106
|
+
'CLOSE_LEG_STATE_DISPATCHED',
|
|
107
|
+
'CLOSE_LEG_STATE_LIVE',
|
|
108
|
+
'EXTRAS_KEY_CLOSE_LEG_STATE',
|
|
109
|
+
'EXTRAS_KEY_CLOSE_LEG_ID',
|
|
110
|
+
'EXTRAS_KEY_CLOSE_PARENT_COID',
|
|
111
|
+
'EXTRAS_KEY_CLOSE_LEG_VOLUME',
|
|
112
|
+
'create_close_leg_row',
|
|
113
|
+
'update_close_leg_state',
|
|
114
|
+
'iter_active_close_legs',
|
|
115
|
+
'STATE_RESIDUAL_OPEN',
|
|
116
|
+
'EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID',
|
|
117
|
+
'EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG',
|
|
118
|
+
'EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS',
|
|
119
|
+
'EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ',
|
|
120
|
+
'create_residual_open_row',
|
|
121
|
+
'iter_active_residual_opens',
|
|
122
|
+
'clear_residual_open_row',
|
|
123
|
+
'STATE_BRACKET_OWN',
|
|
124
|
+
'BRACKET_OWN_STATE_ACTIVE',
|
|
125
|
+
'BRACKET_OWN_STATE_CLEARING',
|
|
126
|
+
'BRACKET_OWN_STATE_RELEASED',
|
|
127
|
+
'BRACKET_OWN_STATE_LIVE',
|
|
128
|
+
'EXTRAS_KEY_BRACKET_OWN_STATE',
|
|
129
|
+
'EXTRAS_KEY_BRACKET_OWN_LEG_ID',
|
|
130
|
+
'EXTRAS_KEY_BRACKET_OWN_ATTACH_COID',
|
|
131
|
+
'EXTRAS_KEY_BRACKET_OWN_CLEAR_COID',
|
|
132
|
+
'EXTRAS_KEY_BRACKET_OWN_TP',
|
|
133
|
+
'EXTRAS_KEY_BRACKET_OWN_SL',
|
|
134
|
+
'EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE',
|
|
135
|
+
'EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET',
|
|
136
|
+
'EXTRAS_KEY_BRACKET_OWN_OCA_NAME',
|
|
137
|
+
'EXTRAS_KEY_BRACKET_OWN_OCA_TYPE',
|
|
138
|
+
'create_bracket_ownership_row',
|
|
139
|
+
'update_bracket_ownership_state',
|
|
140
|
+
'iter_active_bracket_ownerships',
|
|
141
|
+
'ENTRY_STOP_STATE_ARMED',
|
|
142
|
+
'ENTRY_STOP_STATE_CANCEL_PENDING',
|
|
143
|
+
'ENTRY_STOP_STATE_MARKET_PENDING',
|
|
144
|
+
'create_entry_stop_watch_row',
|
|
145
|
+
'ENTRY_STOP_STATE_ABORTABLE',
|
|
146
|
+
'ENTRY_STOP_STATE_ABORTED',
|
|
147
|
+
'ENTRY_STOP_STATE_LIMIT_WON',
|
|
148
|
+
'ENTRY_STOP_STATE_LIVE',
|
|
149
|
+
'ENTRY_STOP_STATE_STOP_WON',
|
|
150
|
+
'EXTRAS_KEY_ENTRY_STOP_LEVEL',
|
|
151
|
+
'EXTRAS_KEY_ENTRY_STOP_LIMIT_COID',
|
|
152
|
+
'EXTRAS_KEY_ENTRY_STOP_MARKET_COID',
|
|
153
|
+
'EXTRAS_KEY_ENTRY_STOP_STATE',
|
|
154
|
+
'iter_active_entry_stop_watches',
|
|
155
|
+
'update_entry_stop_watch_state',
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# === Canonical extras values ===============================================
|
|
160
|
+
|
|
161
|
+
# ``extras['kind']`` for an entry that opens a position immediately
|
|
162
|
+
# (MARKET order). The plugin's reconcile / activity paths key off this
|
|
163
|
+
# to know the row maps to ``/positions`` rather than ``/workingorders``.
|
|
164
|
+
ENTRY_KIND_POSITION = 'position'
|
|
165
|
+
|
|
166
|
+
# ``extras['kind']`` for an entry placed as a working order (LIMIT or
|
|
167
|
+
# STOP). The reconcile / activity paths key off this to know the row
|
|
168
|
+
# maps to ``/workingorders`` rather than ``/positions``.
|
|
169
|
+
ENTRY_KIND_WORKING = 'working'
|
|
170
|
+
|
|
171
|
+
# ``extras['kind']`` for a close that targets every unit of one or more
|
|
172
|
+
# live positions. The plugin issues a DELETE per target ``dealId``; no
|
|
173
|
+
# server reference is allocated (DELETE is fire-and-forget). Recovery
|
|
174
|
+
# reconciles the persisted ``extras['targets']`` against the live
|
|
175
|
+
# positions snapshot.
|
|
176
|
+
KIND_FULL_CLOSE = 'full_close'
|
|
177
|
+
|
|
178
|
+
# ``extras['kind']`` for a close that reduces an existing position by
|
|
179
|
+
# a strict subset of its units. Emulated via an opposite-direction
|
|
180
|
+
# POST to ``/positions`` (Capital.com has no native partial-close
|
|
181
|
+
# endpoint), so the row owns a server reference like an entry would.
|
|
182
|
+
# Recovery uses the ``deal_reference`` confirm GET plus a unit-count
|
|
183
|
+
# delta against the pre/post position snapshot.
|
|
184
|
+
KIND_PARTIAL_CLOSE = 'partial_close_emulated'
|
|
185
|
+
|
|
186
|
+
# Convenience set used by close-aware recovery branches that route by
|
|
187
|
+
# ``extras['kind']`` and need to handle both full and partial close
|
|
188
|
+
# rows identically.
|
|
189
|
+
CLOSE_KINDS: frozenset[str] = frozenset({KIND_FULL_CLOSE, KIND_PARTIAL_CLOSE})
|
|
190
|
+
|
|
191
|
+
# ``extras['kind']`` for a cancel command row. Carries the per-dispatch
|
|
192
|
+
# audit trail for a ``CancelIntent``; the row does not itself land on
|
|
193
|
+
# the exchange. The targets it sweeps are referenced via
|
|
194
|
+
# ``extras['target_coids']`` so a mid-loop crash can re-evaluate which
|
|
195
|
+
# targets still need cancelling on recovery.
|
|
196
|
+
KIND_CANCEL = 'cancel'
|
|
197
|
+
|
|
198
|
+
# ``extras['kind']`` for a working-order amend (PUT level). The row
|
|
199
|
+
# carries the audit trail for the amend dispatch and stores the new
|
|
200
|
+
# level under ``extras['new_level']`` so recovery can verify whether
|
|
201
|
+
# the broker landed the change.
|
|
202
|
+
KIND_MODIFY_ENTRY = 'modify_entry'
|
|
203
|
+
|
|
204
|
+
# ``extras['kind']`` for a position bracket amend (PUT TP / SL /
|
|
205
|
+
# trailing). The row is the entry-side audit trail; the synthetic
|
|
206
|
+
# bracket leg rows that mirror the resulting TP / SL state remain
|
|
207
|
+
# under the plugin's leg state machine for the duration of M4.
|
|
208
|
+
KIND_MODIFY_EXIT = 'modify_exit'
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# === Canonical states ======================================================
|
|
212
|
+
|
|
213
|
+
# Order row persisted, REST POST not yet attempted.
|
|
214
|
+
STATE_SUBMITTED = 'submitted'
|
|
215
|
+
|
|
216
|
+
# REST POST returned a server reference (``dealReference`` for
|
|
217
|
+
# Capital.com). Confirm / readback is the next step.
|
|
218
|
+
STATE_SERVER_REF_SEEN = 'server_ref_seen'
|
|
219
|
+
|
|
220
|
+
# Confirm succeeded; ``exchange_order_id`` populated. For MARKET orders
|
|
221
|
+
# this also implies a fill, with the fill price under
|
|
222
|
+
# ``extras['confirm_level']`` and ``filled_qty`` non-zero.
|
|
223
|
+
STATE_CONFIRMED = 'confirmed'
|
|
224
|
+
|
|
225
|
+
# Exchange rejected the dispatch synchronously (confirm REJECTED, or a
|
|
226
|
+
# 4xx response with a known reject reason). Terminal.
|
|
227
|
+
STATE_REJECTED = 'rejected'
|
|
228
|
+
|
|
229
|
+
# Submission outcome unknown — typically a network timeout between the
|
|
230
|
+
# POST and the response, or a successful POST without a server
|
|
231
|
+
# reference. The recovery path uses these rows on startup to replay or
|
|
232
|
+
# reconcile against the exchange.
|
|
233
|
+
STATE_DISPOSITION_UNKNOWN = 'disposition_unknown'
|
|
234
|
+
|
|
235
|
+
# A full-close DELETE landed at the exchange but the final fill /
|
|
236
|
+
# settlement has not yet been observed in the activity stream. The
|
|
237
|
+
# recovery path treats this state like a pending dispatch: the targets
|
|
238
|
+
# are re-queried against the live positions snapshot to confirm they
|
|
239
|
+
# have actually vanished.
|
|
240
|
+
STATE_CLOSING = 'closing'
|
|
241
|
+
|
|
242
|
+
# A cancel command row is mid-flight: the per-target loop has started
|
|
243
|
+
# but not finished. Recovery re-evaluates the targets against the
|
|
244
|
+
# snapshot and retries the DELETEs that did not land.
|
|
245
|
+
STATE_CANCEL_PENDING = 'cancel_pending'
|
|
246
|
+
|
|
247
|
+
# ``orders.state`` marker for a synthetic engine-trigger partial bracket
|
|
248
|
+
# leg row. The order lifecycle column does NOT track the engine-trigger
|
|
249
|
+
# state machine — the actual leg phase (``armed`` → ``triggering`` →
|
|
250
|
+
# ``triggered`` / ``cascaded_cancel`` / ...) lives under
|
|
251
|
+
# :data:`EXTRAS_KEY_LEG_STATE`. This marker exists so the legacy
|
|
252
|
+
# recovery / reconcile paths can short-circuit on these rows (they own
|
|
253
|
+
# no exchange-side order; the engine state machine in
|
|
254
|
+
# :mod:`software_partial_bracket_engine` owns them).
|
|
255
|
+
STATE_PARTIAL_BRACKET_LEG = 'partial_bracket_leg'
|
|
256
|
+
|
|
257
|
+
# States the recovery path must examine after a restart. ``submitted``
|
|
258
|
+
# is here because a crash between the initial helper and the REST call
|
|
259
|
+
# leaves the row in this state — same recovery semantics as
|
|
260
|
+
# ``disposition_unknown``. The non-entry pending states (``closing``,
|
|
261
|
+
# ``cancel_pending``) are added to this set by the corresponding M4
|
|
262
|
+
# phases once the matching ``resume_pending_dispatch`` branches exist
|
|
263
|
+
# on the plugin side; until then the journal does not surface them to
|
|
264
|
+
# the recovery loop.
|
|
265
|
+
#
|
|
266
|
+
# :data:`STATE_PARTIAL_BRACKET_LEG` is deliberately NOT a member —
|
|
267
|
+
# engine-trigger leg rows are replayed by
|
|
268
|
+
# :meth:`SoftwarePartialBracketEngine.restart_replay`, not by the
|
|
269
|
+
# journal's resume hook.
|
|
270
|
+
PENDING_DISPATCH_STATES: frozenset[str] = frozenset({
|
|
271
|
+
STATE_SUBMITTED,
|
|
272
|
+
STATE_SERVER_REF_SEEN,
|
|
273
|
+
STATE_DISPOSITION_UNKNOWN,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# === Engine-trigger partial bracket leg constants ==========================
|
|
278
|
+
|
|
279
|
+
# ``extras[EXTRAS_KEY_LEG_KIND]`` for the take-profit leg of an
|
|
280
|
+
# engine-trigger partial bracket. The motor closes a strict subset of
|
|
281
|
+
# the parent position at this price level.
|
|
282
|
+
LEG_KIND_TP_PARTIAL = 'tp_partial'
|
|
283
|
+
|
|
284
|
+
# ``extras[EXTRAS_KEY_LEG_KIND]`` for the stop-loss leg of an
|
|
285
|
+
# engine-trigger partial bracket.
|
|
286
|
+
LEG_KIND_SL_PARTIAL = 'sl_partial'
|
|
287
|
+
|
|
288
|
+
# ``extras[EXTRAS_KEY_LEG_KIND]`` for the trailing-stop leg of an
|
|
289
|
+
# engine-trigger partial bracket. The ``trigger_level`` for a trail
|
|
290
|
+
# leg is recomputed by the engine on every favourable price move
|
|
291
|
+
# from ``trigger_offset``.
|
|
292
|
+
LEG_KIND_TRAIL_PARTIAL = 'trail_partial'
|
|
293
|
+
|
|
294
|
+
# Set of leg kinds owned by the engine-trigger state machine —
|
|
295
|
+
# reconciliation / replay paths key off membership here to identify
|
|
296
|
+
# rows whose disposition is NOT the journal's responsibility.
|
|
297
|
+
ENGINE_TRIGGER_LEG_KINDS: frozenset[str] = frozenset({
|
|
298
|
+
LEG_KIND_TP_PARTIAL,
|
|
299
|
+
LEG_KIND_SL_PARTIAL,
|
|
300
|
+
LEG_KIND_TRAIL_PARTIAL,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
# ``extras[EXTRAS_KEY_LEG_STATE]`` values — the engine-trigger state
|
|
304
|
+
# machine's current phase. See the partial-qty bracket exit design
|
|
305
|
+
# dossier §3 for the full lifecycle diagram. ``armed`` is the steady
|
|
306
|
+
# state where the motor watches WS price ticks; ``triggering`` is the
|
|
307
|
+
# transient between trigger detection and the close dispatch landing;
|
|
308
|
+
# ``triggered`` / ``triggered_failed`` / ``triggered_unknown`` are the
|
|
309
|
+
# three close-dispatch outcomes. The ``aborted_*`` and ``cascaded_*``
|
|
310
|
+
# branches are the terminal states for legs that the engine does not
|
|
311
|
+
# fire (parent gone, OCA cancellation, broker-native SL hit).
|
|
312
|
+
LEG_STATE_ARMED = 'armed'
|
|
313
|
+
LEG_STATE_PENDING_ENTRY = 'pending_entry'
|
|
314
|
+
LEG_STATE_TRIGGERING = 'triggering'
|
|
315
|
+
LEG_STATE_TRIGGERED = 'triggered'
|
|
316
|
+
LEG_STATE_TRIGGERED_FAILED = 'triggered_failed'
|
|
317
|
+
LEG_STATE_TRIGGERED_UNKNOWN = 'triggered_unknown'
|
|
318
|
+
LEG_STATE_ABORTED_PARENT_GONE = 'aborted_parent_gone'
|
|
319
|
+
LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED = 'aborted_parent_never_arrived'
|
|
320
|
+
LEG_STATE_CASCADED_CANCEL = 'cascaded_cancel'
|
|
321
|
+
LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE = 'cascaded_cancel_by_parent_close'
|
|
322
|
+
LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL = 'cascaded_cancel_by_native_sl'
|
|
323
|
+
# Intermediate verification state for ``pending_entry`` legs whose
|
|
324
|
+
# parent ``EntryIntent`` was cancelled but the broker's disposition is
|
|
325
|
+
# **unknown** (network timeout, ambiguous response). The leg is
|
|
326
|
+
# neither terminal nor active: it does not contribute to the
|
|
327
|
+
# ``NativeFailsafeManager`` worst-SL set, does not arm on price ticks,
|
|
328
|
+
# and is excluded from the diff-loop adoption path. The sync engine's
|
|
329
|
+
# ``reconcile()`` cancel-retry-loop resolves it within the stale-grace
|
|
330
|
+
# window (default 10s) by re-invoking ``execute_cancel_with_outcome``;
|
|
331
|
+
# the outcome flips the leg to ``aborted_parent_never_arrived`` (on
|
|
332
|
+
# ``CANCEL_CONFIRMED``) or back to ``pending_entry`` / ``armed`` (on
|
|
333
|
+
# ``ALREADY_FILLED``). Stale-grace expiry promotes the parent to
|
|
334
|
+
# ``DEGRADED_HALT``. See the cancel-tentative state design dossier.
|
|
335
|
+
LEG_STATE_CANCEL_TENTATIVE = 'cancel_tentative'
|
|
336
|
+
|
|
337
|
+
# Terminal states — the row is closed (``closed_ts_ms`` is set) and
|
|
338
|
+
# the engine no longer owns it. Used by both
|
|
339
|
+
# :func:`iter_active_engine_trigger_partial_legs` (to filter them out)
|
|
340
|
+
# and the engine's audit emitters.
|
|
341
|
+
LEG_STATE_TERMINAL: frozenset[str] = frozenset({
|
|
342
|
+
LEG_STATE_TRIGGERED,
|
|
343
|
+
LEG_STATE_ABORTED_PARENT_GONE,
|
|
344
|
+
LEG_STATE_ABORTED_PARENT_NEVER_ARRIVED,
|
|
345
|
+
LEG_STATE_CASCADED_CANCEL,
|
|
346
|
+
LEG_STATE_CASCADED_CANCEL_BY_PARENT_CLOSE,
|
|
347
|
+
LEG_STATE_CASCADED_CANCEL_BY_NATIVE_SL,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
# Active states — the row is alive and the engine state machine owns
|
|
351
|
+
# the next transition. :data:`LEG_STATE_TRIGGERED_FAILED` and
|
|
352
|
+
# :data:`LEG_STATE_TRIGGERED_UNKNOWN` are intermediate (the engine
|
|
353
|
+
# retries / reconciles to either ``triggered`` or back to ``armed``).
|
|
354
|
+
LEG_STATE_ACTIVE: frozenset[str] = frozenset({
|
|
355
|
+
LEG_STATE_ARMED,
|
|
356
|
+
LEG_STATE_PENDING_ENTRY,
|
|
357
|
+
LEG_STATE_TRIGGERING,
|
|
358
|
+
LEG_STATE_TRIGGERED_FAILED,
|
|
359
|
+
LEG_STATE_TRIGGERED_UNKNOWN,
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
# Live states — every non-terminal leg state. Superset of
|
|
363
|
+
# :data:`LEG_STATE_ACTIVE` that additionally includes
|
|
364
|
+
# :data:`LEG_STATE_CANCEL_TENTATIVE`: a tentative leg's row is still
|
|
365
|
+
# open (``closed_ts_ms IS NULL``) and must survive restart replay,
|
|
366
|
+
# but the engine does NOT own its next transition — the sync engine's
|
|
367
|
+
# cancel-retry-loop drives it via ``execute_cancel_with_outcome``.
|
|
368
|
+
# Used by :func:`iter_active_engine_trigger_partial_legs` to include
|
|
369
|
+
# tentative rows in restart rehydration, and by the leg-state update
|
|
370
|
+
# validator to permit transitions into ``cancel_tentative``.
|
|
371
|
+
LEG_STATE_LIVE: frozenset[str] = LEG_STATE_ACTIVE | frozenset({
|
|
372
|
+
LEG_STATE_CANCEL_TENTATIVE,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
# Canonical ``extras`` keys for an engine-trigger partial bracket leg
|
|
376
|
+
# row. Defined as string constants so a typo at a call site fails the
|
|
377
|
+
# import rather than producing silent dictionary divergence between
|
|
378
|
+
# the persist helper and the recovery / iteration helpers.
|
|
379
|
+
EXTRAS_KEY_LEG_KIND = 'leg_kind'
|
|
380
|
+
EXTRAS_KEY_LEG_STATE = 'leg_state'
|
|
381
|
+
EXTRAS_KEY_TRIGGER_LEVEL = 'trigger_level'
|
|
382
|
+
EXTRAS_KEY_TRIGGER_OFFSET = 'trigger_offset'
|
|
383
|
+
# Trailing-stop activation level (price units). While set, the trail
|
|
384
|
+
# leg is in its pre-activation phase: the engine only watches whether
|
|
385
|
+
# the current price has crossed this level. Cleared (set to ``None``)
|
|
386
|
+
# the moment activation fires, at which point :data:`EXTRAS_KEY_TRIGGER_LEVEL`
|
|
387
|
+
# starts carrying the moving stop level computed from
|
|
388
|
+
# :data:`EXTRAS_KEY_TRIGGER_OFFSET`.
|
|
389
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL = 'trail_activation_level'
|
|
390
|
+
# Pre-activation activation OFFSET in price units (i.e.
|
|
391
|
+
# ``trail_points_ticks * mintick``). Present only on a trail leg
|
|
392
|
+
# whose parent entry is still pending — at parent fill time the engine
|
|
393
|
+
# resolves it to an absolute :data:`EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL`.
|
|
394
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET = 'trail_activation_offset'
|
|
395
|
+
EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF = 'parent_entry_dispatch_ref'
|
|
396
|
+
EXTRAS_KEY_INTENT_PARTIAL_QTY = 'intent_partial_qty'
|
|
397
|
+
EXTRAS_KEY_PARENT_PINE_ENTRY_ID = 'parent_pine_entry_id'
|
|
398
|
+
EXTRAS_KEY_OCA_GROUP = 'oca_group'
|
|
399
|
+
EXTRAS_KEY_OCA_TYPE = 'oca_type'
|
|
400
|
+
# Wall-clock timestamp (ms) marking when this leg entered the
|
|
401
|
+
# ``cancel_tentative`` state. Persisted on the leg row so that a
|
|
402
|
+
# restart can rehydrate the sync engine's cancel-disposition shadow
|
|
403
|
+
# map without losing the stale-grace deadline. Cleared the moment
|
|
404
|
+
# the leg leaves ``cancel_tentative`` (either back to
|
|
405
|
+
# ``pending_entry`` / ``armed`` on restore, or forward to
|
|
406
|
+
# ``aborted_parent_never_arrived`` on confirmed cancel).
|
|
407
|
+
EXTRAS_KEY_CANCEL_TENTATIVE_SINCE_TS_MS = 'cancel_tentative_since_ts_ms'
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# === Entry-stop watch constants ============================================
|
|
411
|
+
|
|
412
|
+
# ``orders.state`` marker for a synthetic entry-stop WATCH row. A both-set
|
|
413
|
+
# Pine entry (``strategy.entry(limit=, stop=)``) is two OCO legs: the LIMIT
|
|
414
|
+
# leg rests natively as a working order, while the STOP leg is a software
|
|
415
|
+
# price-watch that fires a MARKET order on the stop side. This row carries
|
|
416
|
+
# NO exchange-side order (the native LIMIT and the eventual MARKET have their
|
|
417
|
+
# own rows) — the :class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
|
|
418
|
+
# state machine owns it. Mirrors :data:`STATE_PARTIAL_BRACKET_LEG`: the
|
|
419
|
+
# journal / reconcile paths short-circuit on these rows and the actual watch
|
|
420
|
+
# phase lives under :data:`EXTRAS_KEY_ENTRY_STOP_STATE`.
|
|
421
|
+
STATE_ENTRY_STOP_WATCH = 'entry_stop_watch'
|
|
422
|
+
|
|
423
|
+
# ``extras[EXTRAS_KEY_ENTRY_STOP_STATE]`` values — the entry-stop watch state
|
|
424
|
+
# machine's current phase. ``armed`` watches the price against the stop level
|
|
425
|
+
# while the native LIMIT rests. ``cancel_pending`` is latched once the stop is
|
|
426
|
+
# crossed: the engine cancels the native LIMIT and gates the market on the
|
|
427
|
+
# cancel disposition. ``stop_market_pending`` is reached only after the LIMIT
|
|
428
|
+
# cancel is CONFIRMED — the deterministic MARKET client-order-id is persisted
|
|
429
|
+
# before the POST so a restart verifies-before-resends and never double-opens.
|
|
430
|
+
# The three terminal states record which leg of the OCO won.
|
|
431
|
+
ENTRY_STOP_STATE_ARMED = 'armed'
|
|
432
|
+
ENTRY_STOP_STATE_CANCEL_PENDING = 'cancel_pending'
|
|
433
|
+
ENTRY_STOP_STATE_MARKET_PENDING = 'stop_market_pending'
|
|
434
|
+
ENTRY_STOP_STATE_LIMIT_WON = 'limit_won'
|
|
435
|
+
ENTRY_STOP_STATE_STOP_WON = 'stop_won'
|
|
436
|
+
ENTRY_STOP_STATE_ABORTED = 'aborted'
|
|
437
|
+
|
|
438
|
+
# Terminal states — the row is closed and the engine no longer owns it.
|
|
439
|
+
ENTRY_STOP_STATE_TERMINAL: frozenset[str] = frozenset({
|
|
440
|
+
ENTRY_STOP_STATE_LIMIT_WON,
|
|
441
|
+
ENTRY_STOP_STATE_STOP_WON,
|
|
442
|
+
ENTRY_STOP_STATE_ABORTED,
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
# Live states — the row is alive and the engine state machine drives the next
|
|
446
|
+
# transition. ``cancel_pending`` and ``stop_market_pending`` are latched
|
|
447
|
+
# intermediates: on restart the engine re-drives them deterministically
|
|
448
|
+
# (re-issue the idempotent cancel / re-dispatch the idempotent market) rather
|
|
449
|
+
# than re-evaluating the price.
|
|
450
|
+
ENTRY_STOP_STATE_LIVE: frozenset[str] = frozenset({
|
|
451
|
+
ENTRY_STOP_STATE_ARMED,
|
|
452
|
+
ENTRY_STOP_STATE_CANCEL_PENDING,
|
|
453
|
+
ENTRY_STOP_STATE_MARKET_PENDING,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
# Abortable states — an external cancel / reject of the native LIMIT leg may
|
|
457
|
+
# retire the watch only here. Once the watch has committed to the stop side
|
|
458
|
+
# (``stop_market_pending`` — the deterministic KIND_ENTRY_STOP market id is
|
|
459
|
+
# already persisted and the MARKET is in flight), a delayed broker
|
|
460
|
+
# cancelled/rejected ack for the now-cancelled LIMIT must NOT abort it; that
|
|
461
|
+
# echo would corrupt the persist-first ledger and drop the verify-before-resend
|
|
462
|
+
# watch on restart. ``stop_market_pending`` is therefore deliberately excluded.
|
|
463
|
+
ENTRY_STOP_STATE_ABORTABLE: frozenset[str] = frozenset({
|
|
464
|
+
ENTRY_STOP_STATE_ARMED,
|
|
465
|
+
ENTRY_STOP_STATE_CANCEL_PENDING,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
# Canonical ``extras`` keys for an entry-stop watch row.
|
|
469
|
+
EXTRAS_KEY_ENTRY_STOP_STATE = 'entry_stop_state'
|
|
470
|
+
# Absolute price the watch fires the market at (the Pine entry's ``stop``).
|
|
471
|
+
EXTRAS_KEY_ENTRY_STOP_LEVEL = 'entry_stop_level'
|
|
472
|
+
# The native LIMIT leg's client-order-id — the leg-scoped cancel target.
|
|
473
|
+
EXTRAS_KEY_ENTRY_STOP_LIMIT_COID = 'entry_stop_limit_coid'
|
|
474
|
+
# The stop-fired MARKET order's deterministic client-order-id. Persisted on
|
|
475
|
+
# the transition into ``stop_market_pending`` (BEFORE the POST) so a restart
|
|
476
|
+
# can verify-before-resend.
|
|
477
|
+
EXTRAS_KEY_ENTRY_STOP_MARKET_COID = 'entry_stop_market_coid'
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# === One-way emulation close-leg constants =================================
|
|
481
|
+
|
|
482
|
+
# ``orders.state`` marker for a one-way emulation close-leg row. The row owns no
|
|
483
|
+
# exchange-side order of its own — it records one leg of a ``CloseIntent`` fanned
|
|
484
|
+
# FIFO across a hedging account's legs, so the
|
|
485
|
+
# :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` can re-derive an
|
|
486
|
+
# interrupted fan-out on restart. Like :data:`STATE_PARTIAL_BRACKET_LEG`, this
|
|
487
|
+
# marker tells the journal / reconcile paths to short-circuit: the leg's close
|
|
488
|
+
# FILL itself flows through the normal natural-close path, applied FIFO by
|
|
489
|
+
# ``record_fill`` as a ``LegType.CLOSE``.
|
|
490
|
+
STATE_CLOSE_LEG = 'close_leg'
|
|
491
|
+
|
|
492
|
+
# ``extras[EXTRAS_KEY_CLOSE_LEG_STATE]`` phases of one fanned close leg.
|
|
493
|
+
# ``pending`` — row persisted, the per-leg close has NOT been acked yet.
|
|
494
|
+
# ``dispatched`` — the transport's ``close_leg`` returned without raising
|
|
495
|
+
# (terminal; the row is closed). ``restart_replay`` resumes only ``pending``
|
|
496
|
+
# legs (a crash before the ack) and reconciles each against the live legs
|
|
497
|
+
# before re-dispatching, so an already-executed close is never repeated.
|
|
498
|
+
CLOSE_LEG_STATE_PENDING = 'pending'
|
|
499
|
+
CLOSE_LEG_STATE_DISPATCHED = 'dispatched'
|
|
500
|
+
|
|
501
|
+
# Live (non-terminal) close-leg phases ``restart_replay`` must resume.
|
|
502
|
+
CLOSE_LEG_STATE_LIVE: frozenset[str] = frozenset({CLOSE_LEG_STATE_PENDING})
|
|
503
|
+
|
|
504
|
+
# Canonical ``extras`` keys for a close-leg row.
|
|
505
|
+
EXTRAS_KEY_CLOSE_LEG_STATE = 'close_leg_state'
|
|
506
|
+
# Broker leg id (cTrader ``positionId``) this row closes.
|
|
507
|
+
EXTRAS_KEY_CLOSE_LEG_ID = 'close_leg_id'
|
|
508
|
+
# The owning CloseIntent dispatch's ``KIND_CLOSE`` client-order-id; the per-leg
|
|
509
|
+
# coid is derived deterministically as ``f"{parent}:{leg_id}"`` so a
|
|
510
|
+
# restart-mid-fan never produces two rows for one leg.
|
|
511
|
+
EXTRAS_KEY_CLOSE_PARENT_COID = 'close_parent_coid'
|
|
512
|
+
# Broker-grid integer volume dispatched for this leg (audit + replay residual).
|
|
513
|
+
EXTRAS_KEY_CLOSE_LEG_VOLUME = 'close_leg_volume'
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# === One-way emulation residual-open constants =============================
|
|
517
|
+
|
|
518
|
+
# ``orders.state`` marker for a reversal's residual-OPEN intent. Like
|
|
519
|
+
# :data:`STATE_CLOSE_LEG`, the row owns no exchange order and the journal /
|
|
520
|
+
# reconcile / startup-recovery paths short-circuit on it (they only act on
|
|
521
|
+
# ``submitted`` / ``confirmed`` / ``closing`` / ``rejected`` rows). It is a
|
|
522
|
+
# persist-first breadcrumb written by
|
|
523
|
+
# :meth:`~pynecore.core.broker.one_way_emulator.OneWayEmulator.run_reversal`
|
|
524
|
+
# BEFORE the FIFO closes, so a crash in the window between the closes landing
|
|
525
|
+
# and the residual ``place_leg`` persisting its own entry row does not lose the
|
|
526
|
+
# residual open. :meth:`OneWayEmulator.restart_replay` reconciles it against the
|
|
527
|
+
# residual entry row (re-dispatching the open only when that row never landed)
|
|
528
|
+
# and then clears it.
|
|
529
|
+
STATE_RESIDUAL_OPEN = 'residual_open'
|
|
530
|
+
|
|
531
|
+
# Canonical ``extras`` keys for a residual-open row. The deterministic
|
|
532
|
+
# ``KIND_ENTRY`` coid of the residual ``place_leg`` (existence proves the open
|
|
533
|
+
# already entered the persist-first entry path / 2.x recovery), plus the
|
|
534
|
+
# envelope fields needed to rebuild the dispatch on replay (the residual is
|
|
535
|
+
# always a MARKET entry; the early resting-order branch never persists one).
|
|
536
|
+
EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID = 'residual_open_entry_coid'
|
|
537
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG = 'residual_open_run_tag'
|
|
538
|
+
EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS = 'residual_open_bar_ts_ms'
|
|
539
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ = 'residual_open_retry_seq'
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# === One-way emulation bracket-ownership constants =========================
|
|
543
|
+
|
|
544
|
+
# ``orders.state`` marker for a per-leg bracket-ownership row. Like
|
|
545
|
+
# :data:`STATE_CLOSE_LEG`, the row owns no exchange order: it records that one
|
|
546
|
+
# exit's native TP/SL/trailing bracket was replicated onto one broker leg of a
|
|
547
|
+
# hedging position, so the
|
|
548
|
+
# :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator` can clear ONLY
|
|
549
|
+
# the legs a given exit owns (the plugin's broadcast clear strips brackets a
|
|
550
|
+
# different exit set) and re-assert / release them on restart. The marker tells
|
|
551
|
+
# the journal / reconcile paths to short-circuit, exactly as the close-leg and
|
|
552
|
+
# partial-bracket-leg markers do.
|
|
553
|
+
STATE_BRACKET_OWN = 'bracket_own'
|
|
554
|
+
|
|
555
|
+
# ``extras[EXTRAS_KEY_BRACKET_OWN_STATE]`` phases of one owned leg's bracket.
|
|
556
|
+
# ``active`` — the bracket is replicated onto this leg (steady state). ``clearing``
|
|
557
|
+
# — a clear is in flight: the row is marked PERSIST-FIRST before the amend-to-
|
|
558
|
+
# clear, mirroring the close-leg ``pending`` -> ``dispatched`` two-phase, so an
|
|
559
|
+
# ambiguous (timed-out) clear re-runs the CLEAR on restart instead of
|
|
560
|
+
# re-asserting the original levels (which would resurrect a bracket the script
|
|
561
|
+
# cancelled). ``released`` — the bracket was cleared from this leg (terminal; the
|
|
562
|
+
# row is closed). ``restart_replay`` re-asserts ``active`` rows whose leg is
|
|
563
|
+
# still open, finishes ``clearing`` rows by re-clearing them, and releases any
|
|
564
|
+
# whose leg vanished.
|
|
565
|
+
BRACKET_OWN_STATE_ACTIVE = 'active'
|
|
566
|
+
BRACKET_OWN_STATE_CLEARING = 'clearing'
|
|
567
|
+
BRACKET_OWN_STATE_RELEASED = 'released'
|
|
568
|
+
|
|
569
|
+
# Live (non-terminal) bracket-ownership phases ``restart_replay`` re-asserts /
|
|
570
|
+
# finishes. ``clearing`` stays live so a clear interrupted by a crash or an
|
|
571
|
+
# ambiguous timeout is resumed (re-cleared, then released), never left dangling.
|
|
572
|
+
BRACKET_OWN_STATE_LIVE: frozenset[str] = frozenset(
|
|
573
|
+
{BRACKET_OWN_STATE_ACTIVE, BRACKET_OWN_STATE_CLEARING}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Canonical ``extras`` keys for a bracket-ownership row.
|
|
577
|
+
EXTRAS_KEY_BRACKET_OWN_STATE = 'bracket_own_state'
|
|
578
|
+
# Broker leg id (cTrader ``positionId``) this exit's bracket is replicated onto.
|
|
579
|
+
EXTRAS_KEY_BRACKET_OWN_LEG_ID = 'bracket_own_leg_id'
|
|
580
|
+
# The owning exit's bracket-attach dispatch coid (``KIND_EXIT_SL``); the per-leg
|
|
581
|
+
# row coid is derived as ``f"{attach_coid}:{leg_id}"`` so a re-attach upserts the
|
|
582
|
+
# SAME row and two different exits get disjoint coid namespaces.
|
|
583
|
+
EXTRAS_KEY_BRACKET_OWN_ATTACH_COID = 'bracket_own_attach_coid'
|
|
584
|
+
# The amend-to-clear dispatch coid (``KIND_CANCEL``) recorded PERSIST-FIRST when a
|
|
585
|
+
# row enters ``clearing``, so a restart re-CLEARS under the SAME clear coid instead
|
|
586
|
+
# of the attach coid. ``amend_bracket`` is idempotent on ``coid`` (the contract
|
|
587
|
+
# permits a plugin to dedup a repeated coid), so replaying a clear under the attach
|
|
588
|
+
# coid could be swallowed as a duplicate attach, leaving the bracket armed while the
|
|
589
|
+
# row is released.
|
|
590
|
+
EXTRAS_KEY_BRACKET_OWN_CLEAR_COID = 'bracket_own_clear_coid'
|
|
591
|
+
# Pine-unit protective levels last replicated onto the leg (audit + modify diff).
|
|
592
|
+
EXTRAS_KEY_BRACKET_OWN_TP = 'bracket_own_tp'
|
|
593
|
+
EXTRAS_KEY_BRACKET_OWN_SL = 'bracket_own_sl'
|
|
594
|
+
# Absolute trailing-stop activation price (``ExitIntent.trail_price``). Persisted
|
|
595
|
+
# so a restart can faithfully rebuild a pending-trailing exit's Pine ``Order``;
|
|
596
|
+
# ``trail_offset`` alone cannot distinguish a pre-activation trail from an active
|
|
597
|
+
# one. Absent on rows written before this key existed — reconstruct trail_price
|
|
598
|
+
# as ``None`` in that case (never tear the bracket down over the gap).
|
|
599
|
+
EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE = 'bracket_own_trail_price'
|
|
600
|
+
EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET = 'bracket_own_trail_offset'
|
|
601
|
+
# The owning exit's OCA group identity (``ExitIntent.oca_name`` /
|
|
602
|
+
# ``oca_type``). Persisted so a restart rebuilds the Pine ``Order`` with the same
|
|
603
|
+
# group it was emitted under — otherwise the reconstructed exit carries no OCA
|
|
604
|
+
# group and an explicit ``oca_type='cancel'`` cross-bracket cascade silently
|
|
605
|
+
# stops firing across the restart. Absent on rows written before these keys
|
|
606
|
+
# existed — reconstruct as ``None`` in that case (a single-member synthetic
|
|
607
|
+
# reduce group is a cascade no-op, so the gap only ever loses an explicit group).
|
|
608
|
+
EXTRAS_KEY_BRACKET_OWN_OCA_NAME = 'bracket_own_oca_name'
|
|
609
|
+
EXTRAS_KEY_BRACKET_OWN_OCA_TYPE = 'bracket_own_oca_type'
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# === Entry order row lifecycle =============================================
|
|
613
|
+
|
|
614
|
+
def create_entry_order_row(
|
|
615
|
+
store: 'RunContext',
|
|
616
|
+
*,
|
|
617
|
+
coid: str,
|
|
618
|
+
symbol: str,
|
|
619
|
+
side: str,
|
|
620
|
+
qty: float,
|
|
621
|
+
intent_key: str,
|
|
622
|
+
pine_entry_id: str,
|
|
623
|
+
kind: str,
|
|
624
|
+
order_type: str,
|
|
625
|
+
) -> None:
|
|
626
|
+
"""Insert the initial ``submitted`` row for an entry dispatch.
|
|
627
|
+
|
|
628
|
+
Replaces the manual
|
|
629
|
+
``store.upsert_order(coid, ..., extras={'kind': ..., 'order_type': ...})``
|
|
630
|
+
that every plugin re-implements. The function does **not** write an
|
|
631
|
+
audit event — the orchestrator does that *after* the upsert so a
|
|
632
|
+
crash between the two does not leave an event without its row.
|
|
633
|
+
|
|
634
|
+
:param store: The active run context (`plugin.store_ctx`).
|
|
635
|
+
:param coid: The dispatch's canonical client-order-id.
|
|
636
|
+
:param symbol: Exchange-side symbol (epic for Capital.com, etc.).
|
|
637
|
+
:param side: ``'buy'`` or ``'sell'`` — Pine intent's side.
|
|
638
|
+
:param qty: Already quantized to the broker's lot step.
|
|
639
|
+
:param intent_key: The Pine intent's diff key (e.g. ``pine_id``).
|
|
640
|
+
:param pine_entry_id: The Pine ``strategy.entry(id=...)`` value.
|
|
641
|
+
:param kind: One of :data:`ENTRY_KIND_POSITION`,
|
|
642
|
+
:data:`ENTRY_KIND_WORKING`. Decides how downstream recovery and
|
|
643
|
+
activity reconcile interpret the row.
|
|
644
|
+
:param order_type: The ``OrderType`` enum's ``.value`` (``'market'``,
|
|
645
|
+
``'limit'``, ``'stop'``). Persisted into extras so recovery can
|
|
646
|
+
replay an entry without re-deriving it from the intent.
|
|
647
|
+
"""
|
|
648
|
+
if kind not in (ENTRY_KIND_POSITION, ENTRY_KIND_WORKING):
|
|
649
|
+
raise ValueError(
|
|
650
|
+
f"create_entry_order_row: kind must be one of "
|
|
651
|
+
f"{{ENTRY_KIND_POSITION, ENTRY_KIND_WORKING}}, got {kind!r}"
|
|
652
|
+
)
|
|
653
|
+
# Reopen a previously-closed row sharing this COID before writing the
|
|
654
|
+
# fresh submitted fields. ``DispatchEnvelope.client_order_id`` is a pure
|
|
655
|
+
# function of (run_tag, pine_id, bar_ts_ms, kind, retry_seq), so a Pine
|
|
656
|
+
# strategy that cancels an order and re-creates it under the same id
|
|
657
|
+
# within the same bar produces an *identical* COID. The first lifecycle
|
|
658
|
+
# closed the row (``closed_ts_ms`` set); ``upsert_order`` then takes its
|
|
659
|
+
# UPDATE path, which does not touch ``closed_ts_ms`` — leaving the
|
|
660
|
+
# re-created order invisible to ``iter_live_orders`` and therefore to the
|
|
661
|
+
# cancel sweep, reconcile, and fill-fallback. A later ``strategy.cancel``
|
|
662
|
+
# finds no live target and degrades to a no-op while the order is still
|
|
663
|
+
# live on the exchange. Clearing ``closed_ts_ms`` and resetting the
|
|
664
|
+
# per-lifecycle fields (exchange id, fill) restores true insert semantics
|
|
665
|
+
# for the fresh submission. Mirrors the plugin-side TP/SL reopen guard.
|
|
666
|
+
existing = store.get_order(coid)
|
|
667
|
+
if existing is not None and existing.closed_ts_ms is not None:
|
|
668
|
+
store.reopen_order(coid)
|
|
669
|
+
store.upsert_order(coid, exchange_order_id=None, filled_qty=0.0)
|
|
670
|
+
store.upsert_order(
|
|
671
|
+
coid,
|
|
672
|
+
symbol=symbol,
|
|
673
|
+
side=side,
|
|
674
|
+
qty=qty,
|
|
675
|
+
state=STATE_SUBMITTED,
|
|
676
|
+
intent_key=intent_key,
|
|
677
|
+
pine_entry_id=pine_entry_id,
|
|
678
|
+
extras={'kind': kind, 'order_type': order_type},
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def record_server_ref(
|
|
683
|
+
store: 'RunContext',
|
|
684
|
+
*,
|
|
685
|
+
coid: str,
|
|
686
|
+
deal_reference: str,
|
|
687
|
+
kind: str,
|
|
688
|
+
order_type: str,
|
|
689
|
+
) -> None:
|
|
690
|
+
"""Record the exchange's submission reference + advance state.
|
|
691
|
+
|
|
692
|
+
Two writes in a defined order:
|
|
693
|
+
|
|
694
|
+
1. ``order_refs`` row keyed by ``'deal_reference'`` so activity /
|
|
695
|
+
reconcile can resolve the reference back to the COID with a
|
|
696
|
+
single indexed lookup.
|
|
697
|
+
2. ``orders.extras`` updated to mirror the reference *together
|
|
698
|
+
with* ``orders.state`` advanced to
|
|
699
|
+
:data:`STATE_SERVER_REF_SEEN` — single
|
|
700
|
+
:meth:`RunContext.upsert_order` transaction, so state + extras
|
|
701
|
+
can never disagree.
|
|
702
|
+
|
|
703
|
+
Crash-safety: if the process crashes between (1) and (2), the row
|
|
704
|
+
is still in :data:`STATE_SUBMITTED` (pending), and the resume hook
|
|
705
|
+
sees the ``deal_reference`` via the ``order_refs`` table —
|
|
706
|
+
:func:`~pynecore.core.broker.journal._collect_refs_for` materialises
|
|
707
|
+
it from there. That is the contract :func:`find_pending_dispatch`
|
|
708
|
+
relies on.
|
|
709
|
+
|
|
710
|
+
:param store: The active run context.
|
|
711
|
+
:param coid: The dispatch's COID.
|
|
712
|
+
:param deal_reference: Server-allocated reference for the POST.
|
|
713
|
+
:param kind: Same value originally passed to
|
|
714
|
+
:func:`create_entry_order_row`. Repeated here because
|
|
715
|
+
``upsert_order(extras=...)`` overwrites the whole dict.
|
|
716
|
+
:param order_type: Same value originally passed to
|
|
717
|
+
:func:`create_entry_order_row`. Repeated for the same reason.
|
|
718
|
+
"""
|
|
719
|
+
store.add_ref(coid, 'deal_reference', deal_reference)
|
|
720
|
+
store.upsert_order(
|
|
721
|
+
coid,
|
|
722
|
+
state=STATE_SERVER_REF_SEEN,
|
|
723
|
+
extras={
|
|
724
|
+
'kind': kind,
|
|
725
|
+
'order_type': order_type,
|
|
726
|
+
'deal_reference': deal_reference,
|
|
727
|
+
},
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def mark_confirmed_with_fill(
|
|
732
|
+
store: 'RunContext',
|
|
733
|
+
*,
|
|
734
|
+
coid: str,
|
|
735
|
+
exchange_id: str | None,
|
|
736
|
+
is_filled: bool,
|
|
737
|
+
filled_qty: float,
|
|
738
|
+
fill_price: float | None,
|
|
739
|
+
) -> None:
|
|
740
|
+
"""Promote the row to ``confirmed``, optionally recording a fill.
|
|
741
|
+
|
|
742
|
+
A MARKET entry that fills immediately needs four facts persisted in
|
|
743
|
+
a single logical step:
|
|
744
|
+
|
|
745
|
+
- ``order_refs`` row keyed by ``'deal_id'`` (so activity tail rows
|
|
746
|
+
that carry only the deal id can map back).
|
|
747
|
+
- ``orders.exchange_order_id`` populated.
|
|
748
|
+
- ``orders.state`` = :data:`STATE_CONFIRMED`.
|
|
749
|
+
- ``orders.filled_qty`` set and ``extras['confirm_level']`` set
|
|
750
|
+
from the confirm response, as a recovery fallback when the
|
|
751
|
+
``/history/activity`` row arrives with ``level=0`` and the
|
|
752
|
+
``/positions`` snapshot is also empty for this deal id.
|
|
753
|
+
|
|
754
|
+
LIMIT / STOP entries call this with ``is_filled=False``; only the
|
|
755
|
+
first three facts apply.
|
|
756
|
+
|
|
757
|
+
:param store: The active run context.
|
|
758
|
+
:param coid: The dispatch's COID.
|
|
759
|
+
:param exchange_id: Exchange-allocated id (``dealId`` for
|
|
760
|
+
Capital.com). May be ``None`` if confirm returns no id — the
|
|
761
|
+
function still advances state but skips the id-related writes.
|
|
762
|
+
:param is_filled: ``True`` only for MARKET-side fills that confirm
|
|
763
|
+
as OPEN. LIMIT / STOP submissions land as live working orders
|
|
764
|
+
and pass ``False`` here; the fill arrives later via the
|
|
765
|
+
activity stream.
|
|
766
|
+
:param filled_qty: Confirmed fill quantity. Ignored when
|
|
767
|
+
``is_filled`` is ``False``.
|
|
768
|
+
:param fill_price: Confirm-side fill price. Persisted under
|
|
769
|
+
``extras['confirm_level']`` only when ``is_filled`` is ``True``
|
|
770
|
+
and the value is strictly positive — a zero/negative level is
|
|
771
|
+
a no-quote artefact and would corrupt the recovery fallback.
|
|
772
|
+
|
|
773
|
+
Crash-safety: ``exchange_order_id``, ``state``, ``filled_qty``, and
|
|
774
|
+
the merged ``extras`` (with ``confirm_level``) are written in a
|
|
775
|
+
*single* :meth:`RunContext.upsert_order` transaction, so a crash
|
|
776
|
+
cannot leave the row in :data:`STATE_CONFIRMED` (terminal) without
|
|
777
|
+
its fill details. The ``deal_id`` ref is written first as a separate
|
|
778
|
+
transaction — if a crash occurs between the ref write and the
|
|
779
|
+
consolidated update, the row's state is still
|
|
780
|
+
:data:`STATE_SERVER_REF_SEEN` (pending), so
|
|
781
|
+
:func:`find_pending_dispatch` re-yields it and the resume hook can
|
|
782
|
+
rebuild the confirmation from the ``deal_id`` already in
|
|
783
|
+
``order_refs``.
|
|
784
|
+
"""
|
|
785
|
+
if exchange_id:
|
|
786
|
+
store.add_ref(coid, 'deal_id', exchange_id)
|
|
787
|
+
|
|
788
|
+
fields: dict[str, Any] = {'state': STATE_CONFIRMED}
|
|
789
|
+
if exchange_id:
|
|
790
|
+
fields['exchange_order_id'] = exchange_id
|
|
791
|
+
if is_filled:
|
|
792
|
+
fields['filled_qty'] = filled_qty
|
|
793
|
+
if fill_price is not None and fill_price > 0.0:
|
|
794
|
+
existing = store.get_order(coid)
|
|
795
|
+
merged = dict(existing.extras or {}) if existing is not None else {}
|
|
796
|
+
merged['confirm_level'] = fill_price
|
|
797
|
+
fields['extras'] = merged
|
|
798
|
+
store.upsert_order(coid, **fields)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def mark_disposition_unknown(
|
|
802
|
+
store: 'RunContext',
|
|
803
|
+
*,
|
|
804
|
+
coid: str,
|
|
805
|
+
) -> None:
|
|
806
|
+
"""Flip the row to :data:`STATE_DISPOSITION_UNKNOWN`.
|
|
807
|
+
|
|
808
|
+
Used when a POST times out or the response is missing the server
|
|
809
|
+
reference. The row is *not* deleted — recovery on the next restart
|
|
810
|
+
re-evaluates it against the exchange's authoritative view.
|
|
811
|
+
"""
|
|
812
|
+
store.set_order_state(coid, STATE_DISPOSITION_UNKNOWN)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def mark_rejected(
|
|
816
|
+
store: 'RunContext',
|
|
817
|
+
*,
|
|
818
|
+
coid: str,
|
|
819
|
+
) -> None:
|
|
820
|
+
"""Flip the row to :data:`STATE_REJECTED` (terminal).
|
|
821
|
+
|
|
822
|
+
Used when the exchange returns a definitive reject — confirm
|
|
823
|
+
``REJECTED``, a 4xx response, or a synchronous reason string the
|
|
824
|
+
plugin maps to ``ExchangeOrderRejectedError``. The row stays in
|
|
825
|
+
place for audit; the engine clears its intent slot on the next
|
|
826
|
+
sync.
|
|
827
|
+
"""
|
|
828
|
+
store.set_order_state(coid, STATE_REJECTED)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# === Recovery query ========================================================
|
|
832
|
+
|
|
833
|
+
def find_pending_dispatch(store: 'RunContext') -> Iterator['OrderRow']:
|
|
834
|
+
"""Yield live rows whose dispatch the journal still owns.
|
|
835
|
+
|
|
836
|
+
A row is *pending* when its state is in
|
|
837
|
+
:data:`PENDING_DISPATCH_STATES`. The journal's
|
|
838
|
+
:meth:`~pynecore.core.broker.journal.DispatchJournal.recover_pending`
|
|
839
|
+
iterates these on startup and asks the plugin's
|
|
840
|
+
``resume_pending_dispatch`` hook for a verdict.
|
|
841
|
+
|
|
842
|
+
The query relies on the partial ``idx_orders_live`` index, so the
|
|
843
|
+
cost is O(log n) per call.
|
|
844
|
+
"""
|
|
845
|
+
for row in store.iter_live_orders():
|
|
846
|
+
if row.state in PENDING_DISPATCH_STATES:
|
|
847
|
+
yield row
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# === Close lifecycle =======================================================
|
|
851
|
+
|
|
852
|
+
def create_close_target_row(
|
|
853
|
+
store: 'RunContext',
|
|
854
|
+
*,
|
|
855
|
+
coid: str,
|
|
856
|
+
symbol: str,
|
|
857
|
+
side: str,
|
|
858
|
+
qty: float,
|
|
859
|
+
intent_key: str,
|
|
860
|
+
kind: str,
|
|
861
|
+
pine_entry_id: str | None = None,
|
|
862
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
863
|
+
) -> None:
|
|
864
|
+
"""Insert the initial ``submitted`` row for a close dispatch.
|
|
865
|
+
|
|
866
|
+
Both full-close and partial-close dispatches start with the same
|
|
867
|
+
persist-first row; the ``kind`` discriminator (:data:`KIND_FULL_CLOSE`
|
|
868
|
+
vs :data:`KIND_PARTIAL_CLOSE`) decides the downstream lifecycle and
|
|
869
|
+
the recovery contract.
|
|
870
|
+
|
|
871
|
+
:param store: The active run context.
|
|
872
|
+
:param coid: The dispatch's COID
|
|
873
|
+
(``envelope.client_order_id(KIND_CLOSE)``).
|
|
874
|
+
:param symbol: Exchange-side symbol (epic).
|
|
875
|
+
:param side: Side of the closing leg — for a full close this is
|
|
876
|
+
the side of the position being closed; for a partial close
|
|
877
|
+
this is the opposite-direction emulation side.
|
|
878
|
+
:param qty: Quantity to close, already quantized.
|
|
879
|
+
:param intent_key: The Pine intent's diff key.
|
|
880
|
+
:param kind: :data:`KIND_FULL_CLOSE` or :data:`KIND_PARTIAL_CLOSE`.
|
|
881
|
+
:param pine_entry_id: Optional Pine ``strategy.entry(id=...)`` value
|
|
882
|
+
when the close is tied to a single named entry. ``None`` when
|
|
883
|
+
the close sweeps multiple positions (e.g. full close across
|
|
884
|
+
ids).
|
|
885
|
+
:param extra_payload: Optional extras to merge in alongside the
|
|
886
|
+
canonical ``kind`` key. Use for plugin-specific bookkeeping
|
|
887
|
+
the recovery contract needs (e.g. ``pre_total_units``,
|
|
888
|
+
``intent_units`` for the partial-close emulation).
|
|
889
|
+
"""
|
|
890
|
+
if kind not in CLOSE_KINDS:
|
|
891
|
+
raise ValueError(
|
|
892
|
+
f"create_close_target_row: kind must be one of {{KIND_FULL_CLOSE, "
|
|
893
|
+
f"KIND_PARTIAL_CLOSE}}, got {kind!r}"
|
|
894
|
+
)
|
|
895
|
+
extras: dict[str, Any] = {'kind': kind}
|
|
896
|
+
if extra_payload:
|
|
897
|
+
extras.update(extra_payload)
|
|
898
|
+
store.upsert_order(
|
|
899
|
+
coid,
|
|
900
|
+
symbol=symbol,
|
|
901
|
+
side=side,
|
|
902
|
+
qty=qty,
|
|
903
|
+
state=STATE_SUBMITTED,
|
|
904
|
+
intent_key=intent_key,
|
|
905
|
+
pine_entry_id=pine_entry_id,
|
|
906
|
+
extras=extras,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def record_close_server_ref(
|
|
911
|
+
store: 'RunContext',
|
|
912
|
+
*,
|
|
913
|
+
coid: str,
|
|
914
|
+
deal_reference: str,
|
|
915
|
+
kind: str,
|
|
916
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Record the partial-close POST's server reference + advance state.
|
|
919
|
+
|
|
920
|
+
Only the partial-close path issues a POST that allocates a
|
|
921
|
+
``dealReference``; the full-close DELETE returns no such id, so
|
|
922
|
+
this helper is not called there. The two-write order matches
|
|
923
|
+
:func:`record_server_ref`: ``order_refs`` first, then a consolidated
|
|
924
|
+
``upsert_order`` that mirrors the reference into ``extras`` and
|
|
925
|
+
advances state to :data:`STATE_SERVER_REF_SEEN`.
|
|
926
|
+
|
|
927
|
+
:param store: The active run context.
|
|
928
|
+
:param coid: The dispatch's COID.
|
|
929
|
+
:param deal_reference: Server-allocated reference for the POST.
|
|
930
|
+
:param kind: Must be :data:`KIND_PARTIAL_CLOSE`. Re-stated because
|
|
931
|
+
``upsert_order(extras=...)`` overwrites the whole dict.
|
|
932
|
+
:param extra_payload: Optional extras to preserve through the
|
|
933
|
+
state advance (e.g. ``pre_total_units``, ``intent_units``).
|
|
934
|
+
"""
|
|
935
|
+
if kind != KIND_PARTIAL_CLOSE:
|
|
936
|
+
raise ValueError(
|
|
937
|
+
f"record_close_server_ref: kind must be {KIND_PARTIAL_CLOSE!r}, "
|
|
938
|
+
f"got {kind!r}"
|
|
939
|
+
)
|
|
940
|
+
store.add_ref(coid, 'deal_reference', deal_reference)
|
|
941
|
+
# ``upsert_order(extras=...)`` overwrites the whole dict, so read the
|
|
942
|
+
# current extras first and merge: plugin-side context written between
|
|
943
|
+
# row creation and this helper (e.g. ``pre_total_units`` /
|
|
944
|
+
# ``intent_units`` from the partial-close emulation's pre-snapshot)
|
|
945
|
+
# must survive the state advance so recovery has them.
|
|
946
|
+
existing = store.get_order(coid)
|
|
947
|
+
extras: dict[str, Any] = dict(existing.extras or {}) if existing else {}
|
|
948
|
+
extras['kind'] = kind
|
|
949
|
+
extras['deal_reference'] = deal_reference
|
|
950
|
+
if extra_payload:
|
|
951
|
+
extras.update(extra_payload)
|
|
952
|
+
store.upsert_order(
|
|
953
|
+
coid,
|
|
954
|
+
state=STATE_SERVER_REF_SEEN,
|
|
955
|
+
extras=extras,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def mark_closing(
|
|
960
|
+
store: 'RunContext',
|
|
961
|
+
*,
|
|
962
|
+
coid: str,
|
|
963
|
+
kind: str,
|
|
964
|
+
targets: list[str],
|
|
965
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
966
|
+
) -> None:
|
|
967
|
+
"""Flip a full-close row to :data:`STATE_CLOSING` and pin its targets.
|
|
968
|
+
|
|
969
|
+
Called after every DELETE has been issued but before the activity
|
|
970
|
+
stream has confirmed the resulting fills. The ``targets`` list is
|
|
971
|
+
persisted under ``extras['targets']`` so a restart-mid-stream can
|
|
972
|
+
reconcile each ``dealId`` against the live positions snapshot.
|
|
973
|
+
|
|
974
|
+
:param store: The active run context.
|
|
975
|
+
:param coid: The dispatch's COID.
|
|
976
|
+
:param kind: Must be :data:`KIND_FULL_CLOSE`. Partial-close uses
|
|
977
|
+
:func:`mark_close_completed` directly because its emulation
|
|
978
|
+
finishes synchronously inside the POST.
|
|
979
|
+
:param targets: Exchange ``dealId`` strings the DELETE chain
|
|
980
|
+
targeted. Empty list is a no-op close (the engine should have
|
|
981
|
+
elided the dispatch, but this is harmless).
|
|
982
|
+
:param extra_payload: Optional extras to preserve.
|
|
983
|
+
"""
|
|
984
|
+
if kind != KIND_FULL_CLOSE:
|
|
985
|
+
raise ValueError(
|
|
986
|
+
f"mark_closing: kind must be {KIND_FULL_CLOSE!r}, got {kind!r}"
|
|
987
|
+
)
|
|
988
|
+
extras: dict[str, Any] = {
|
|
989
|
+
'kind': kind,
|
|
990
|
+
'targets': list(targets),
|
|
991
|
+
}
|
|
992
|
+
if extra_payload:
|
|
993
|
+
extras.update(extra_payload)
|
|
994
|
+
store.upsert_order(
|
|
995
|
+
coid,
|
|
996
|
+
state=STATE_CLOSING,
|
|
997
|
+
extras=extras,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def mark_close_completed(
|
|
1002
|
+
store: 'RunContext',
|
|
1003
|
+
*,
|
|
1004
|
+
coid: str,
|
|
1005
|
+
kind: str,
|
|
1006
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1007
|
+
) -> None:
|
|
1008
|
+
"""Finalise a close dispatch row's state to ``confirmed``.
|
|
1009
|
+
|
|
1010
|
+
For full close, called once every target's vanished position has
|
|
1011
|
+
been observed (or the recovery path has decided the targets are
|
|
1012
|
+
gone). For partial close, called when the POST + post-snapshot
|
|
1013
|
+
reconcile confirm the unit-count delta matches.
|
|
1014
|
+
|
|
1015
|
+
Does **not** call :meth:`RunContext.close_order` — the journal
|
|
1016
|
+
issues that as a separate step *after* it has emitted the
|
|
1017
|
+
``confirmed`` audit event, so the event order is
|
|
1018
|
+
``dispatch_submitted → deal_reference_seen → confirmed →
|
|
1019
|
+
order_closed``.
|
|
1020
|
+
|
|
1021
|
+
:param store: The active run context.
|
|
1022
|
+
:param coid: The dispatch's COID.
|
|
1023
|
+
:param kind: :data:`KIND_FULL_CLOSE` or :data:`KIND_PARTIAL_CLOSE`.
|
|
1024
|
+
:param extra_payload: Optional extras to preserve.
|
|
1025
|
+
"""
|
|
1026
|
+
if kind not in CLOSE_KINDS:
|
|
1027
|
+
raise ValueError(
|
|
1028
|
+
f"mark_close_completed: kind must be one of {{KIND_FULL_CLOSE, "
|
|
1029
|
+
f"KIND_PARTIAL_CLOSE}}, got {kind!r}"
|
|
1030
|
+
)
|
|
1031
|
+
fields: dict[str, Any] = {'state': STATE_CONFIRMED}
|
|
1032
|
+
if extra_payload:
|
|
1033
|
+
existing = store.get_order(coid)
|
|
1034
|
+
merged = dict(existing.extras or {}) if existing is not None else {}
|
|
1035
|
+
merged.update(extra_payload)
|
|
1036
|
+
fields['extras'] = merged
|
|
1037
|
+
store.upsert_order(coid, **fields)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
# === Cancel lifecycle ======================================================
|
|
1041
|
+
|
|
1042
|
+
def create_cancel_command_row(
|
|
1043
|
+
store: 'RunContext',
|
|
1044
|
+
*,
|
|
1045
|
+
coid: str,
|
|
1046
|
+
symbol: str,
|
|
1047
|
+
side: str,
|
|
1048
|
+
qty: float,
|
|
1049
|
+
intent_key: str,
|
|
1050
|
+
pine_entry_id: str | None = None,
|
|
1051
|
+
from_entry: str | None = None,
|
|
1052
|
+
target_coids: list[str] | None = None,
|
|
1053
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1054
|
+
) -> None:
|
|
1055
|
+
"""Insert the initial ``submitted`` row for a cancel dispatch.
|
|
1056
|
+
|
|
1057
|
+
A cancel row is the per-dispatch audit trail for a ``CancelIntent``:
|
|
1058
|
+
it carries the list of target COIDs the per-target loop will sweep,
|
|
1059
|
+
so a mid-loop crash can resume cleanly. The row itself does not
|
|
1060
|
+
land at the exchange — only its targets do (via DELETE / PUT-null).
|
|
1061
|
+
|
|
1062
|
+
:param store: The active run context.
|
|
1063
|
+
:param coid: The cancel dispatch's COID
|
|
1064
|
+
(``envelope.client_order_id(KIND_CANCEL)``).
|
|
1065
|
+
:param symbol: Exchange-side symbol of the targets.
|
|
1066
|
+
:param side: Side of the targets (uniform across a single dispatch).
|
|
1067
|
+
:param qty: Aggregate quantity of the targets — for diagnostics
|
|
1068
|
+
only; the recovery contract uses ``target_coids`` directly.
|
|
1069
|
+
:param intent_key: The Pine intent's diff key.
|
|
1070
|
+
:param pine_entry_id: ``strategy.entry(id=...)`` value the cancel
|
|
1071
|
+
addresses, or ``None`` for cross-entry cancels.
|
|
1072
|
+
:param from_entry: ``ExitIntent`` ``from_entry`` echo when the
|
|
1073
|
+
cancel sweeps a bracket leg, ``None`` otherwise.
|
|
1074
|
+
:param target_coids: COIDs the per-target loop intends to sweep.
|
|
1075
|
+
Persisted under ``extras['target_coids']`` so recovery can
|
|
1076
|
+
re-evaluate after a mid-loop crash. Empty list is permitted —
|
|
1077
|
+
the journal will still emit the audit trail and finalise the
|
|
1078
|
+
row with :func:`mark_cancel_completed`.
|
|
1079
|
+
:param extra_payload: Optional extras to merge in.
|
|
1080
|
+
"""
|
|
1081
|
+
extras: dict[str, Any] = {
|
|
1082
|
+
'kind': KIND_CANCEL,
|
|
1083
|
+
'target_coids': list(target_coids or ()),
|
|
1084
|
+
}
|
|
1085
|
+
if extra_payload:
|
|
1086
|
+
extras.update(extra_payload)
|
|
1087
|
+
store.upsert_order(
|
|
1088
|
+
coid,
|
|
1089
|
+
symbol=symbol,
|
|
1090
|
+
side=side,
|
|
1091
|
+
qty=qty,
|
|
1092
|
+
state=STATE_SUBMITTED,
|
|
1093
|
+
intent_key=intent_key,
|
|
1094
|
+
pine_entry_id=pine_entry_id,
|
|
1095
|
+
from_entry=from_entry,
|
|
1096
|
+
extras=extras,
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def mark_cancel_completed(
|
|
1101
|
+
store: 'RunContext',
|
|
1102
|
+
*,
|
|
1103
|
+
coid: str,
|
|
1104
|
+
reason_path: str,
|
|
1105
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1106
|
+
) -> None:
|
|
1107
|
+
"""Finalise a cancel command row's state to ``confirmed``.
|
|
1108
|
+
|
|
1109
|
+
Called after the per-target loop has finished. ``reason_path``
|
|
1110
|
+
records why the cancel resolved this way (``'deleted'`` when the
|
|
1111
|
+
targets were swept, ``'already_gone'`` when every target had
|
|
1112
|
+
vanished benignly, ``'noop'`` when nothing matched). The value is
|
|
1113
|
+
preserved in ``extras['reason_path']`` for forensics. Does **not**
|
|
1114
|
+
call :meth:`RunContext.close_order` — see
|
|
1115
|
+
:func:`mark_close_completed` for the rationale.
|
|
1116
|
+
|
|
1117
|
+
:param store: The active run context.
|
|
1118
|
+
:param coid: The cancel dispatch's COID.
|
|
1119
|
+
:param reason_path: One of ``'deleted'``, ``'already_gone'``,
|
|
1120
|
+
``'noop'`` (the canonical :class:`CancelOutcome.reason_path`
|
|
1121
|
+
values).
|
|
1122
|
+
:param extra_payload: Optional extras to preserve.
|
|
1123
|
+
"""
|
|
1124
|
+
existing = store.get_order(coid)
|
|
1125
|
+
merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
|
|
1126
|
+
merged['reason_path'] = reason_path
|
|
1127
|
+
if extra_payload:
|
|
1128
|
+
merged.update(extra_payload)
|
|
1129
|
+
store.upsert_order(
|
|
1130
|
+
coid,
|
|
1131
|
+
state=STATE_CONFIRMED,
|
|
1132
|
+
extras=merged,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# === Modify lifecycle ======================================================
|
|
1137
|
+
|
|
1138
|
+
def create_modify_entry_row(
|
|
1139
|
+
store: 'RunContext',
|
|
1140
|
+
*,
|
|
1141
|
+
coid: str,
|
|
1142
|
+
target_coid: str,
|
|
1143
|
+
symbol: str,
|
|
1144
|
+
side: str,
|
|
1145
|
+
qty: float,
|
|
1146
|
+
intent_key: str,
|
|
1147
|
+
new_level: float,
|
|
1148
|
+
pine_entry_id: str | None = None,
|
|
1149
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1150
|
+
) -> None:
|
|
1151
|
+
"""Insert the initial ``submitted`` row for a working-order amend.
|
|
1152
|
+
|
|
1153
|
+
The amend dispatch produces its own COID + audit row separate from
|
|
1154
|
+
the working order it mutates. ``target_coid`` and ``new_level``
|
|
1155
|
+
travel through ``extras`` so a restart-mid-amend can verify the
|
|
1156
|
+
broker landed the change.
|
|
1157
|
+
|
|
1158
|
+
:param store: The active run context.
|
|
1159
|
+
:param coid: The amend dispatch's COID
|
|
1160
|
+
(``envelope.client_order_id(KIND_MODIFY)`` for the new envelope).
|
|
1161
|
+
:param target_coid: COID of the working order being amended.
|
|
1162
|
+
:param symbol: Exchange-side symbol.
|
|
1163
|
+
:param side: Side of the target order.
|
|
1164
|
+
:param qty: Order quantity (unchanged by the amend).
|
|
1165
|
+
:param intent_key: The Pine intent's diff key.
|
|
1166
|
+
:param new_level: Requested new ``level`` for the working order.
|
|
1167
|
+
:param pine_entry_id: ``strategy.entry(id=...)`` echo.
|
|
1168
|
+
:param extra_payload: Optional extras to merge in.
|
|
1169
|
+
"""
|
|
1170
|
+
extras: dict[str, Any] = {
|
|
1171
|
+
'kind': KIND_MODIFY_ENTRY,
|
|
1172
|
+
'target_coid': target_coid,
|
|
1173
|
+
'new_level': new_level,
|
|
1174
|
+
}
|
|
1175
|
+
if extra_payload:
|
|
1176
|
+
extras.update(extra_payload)
|
|
1177
|
+
store.upsert_order(
|
|
1178
|
+
coid,
|
|
1179
|
+
symbol=symbol,
|
|
1180
|
+
side=side,
|
|
1181
|
+
qty=qty,
|
|
1182
|
+
state=STATE_SUBMITTED,
|
|
1183
|
+
intent_key=intent_key,
|
|
1184
|
+
pine_entry_id=pine_entry_id,
|
|
1185
|
+
extras=extras,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def create_modify_exit_row(
|
|
1190
|
+
store: 'RunContext',
|
|
1191
|
+
*,
|
|
1192
|
+
coid: str,
|
|
1193
|
+
target_coid: str,
|
|
1194
|
+
symbol: str,
|
|
1195
|
+
side: str,
|
|
1196
|
+
qty: float,
|
|
1197
|
+
intent_key: str,
|
|
1198
|
+
new_tp: float | None,
|
|
1199
|
+
new_sl: float | None,
|
|
1200
|
+
new_trail: float | None,
|
|
1201
|
+
new_trail_price: float | None = None,
|
|
1202
|
+
pine_entry_id: str | None = None,
|
|
1203
|
+
from_entry: str | None = None,
|
|
1204
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1205
|
+
) -> None:
|
|
1206
|
+
"""Insert the initial ``submitted`` row for a position bracket amend.
|
|
1207
|
+
|
|
1208
|
+
Mirrors :func:`create_modify_entry_row` but for ``ExitIntent``
|
|
1209
|
+
amends: the target is the entry-row representing the position and
|
|
1210
|
+
the requested change covers TP / SL / trailing levels. Recovery
|
|
1211
|
+
reads ``new_tp`` / ``new_sl`` / ``new_trail`` / ``new_trail_price``
|
|
1212
|
+
from ``extras`` and compares them against the post-amend snapshot.
|
|
1213
|
+
|
|
1214
|
+
``new_trail_price`` is Pine's trailing-stop *activation* price
|
|
1215
|
+
(``strategy.exit(trail_price=...)``). When both ``new_trail`` and
|
|
1216
|
+
``new_trail_price`` are set the bracket is **pending trailing** —
|
|
1217
|
+
the broker carries no native trailing stop yet (the local
|
|
1218
|
+
activation monitor will PUT one once price crosses the threshold),
|
|
1219
|
+
so the post-amend snapshot legitimately shows ``trailingStop=False``
|
|
1220
|
+
and recovery must NOT require ``trailingStop=True`` to declare the
|
|
1221
|
+
amend landed. Persisting the activation price is what lets the
|
|
1222
|
+
snapshot verdict distinguish the two trailing shapes.
|
|
1223
|
+
|
|
1224
|
+
Synthetic bracket leg rows (``leg_kind`` ``'tp'`` / ``'sl'``)
|
|
1225
|
+
remain under the plugin's leg state machine for M4; this helper
|
|
1226
|
+
persists only the entry-side audit trail.
|
|
1227
|
+
"""
|
|
1228
|
+
extras: dict[str, Any] = {
|
|
1229
|
+
'kind': KIND_MODIFY_EXIT,
|
|
1230
|
+
'target_coid': target_coid,
|
|
1231
|
+
'new_tp': new_tp,
|
|
1232
|
+
'new_sl': new_sl,
|
|
1233
|
+
'new_trail': new_trail,
|
|
1234
|
+
'new_trail_price': new_trail_price,
|
|
1235
|
+
}
|
|
1236
|
+
if extra_payload:
|
|
1237
|
+
extras.update(extra_payload)
|
|
1238
|
+
store.upsert_order(
|
|
1239
|
+
coid,
|
|
1240
|
+
symbol=symbol,
|
|
1241
|
+
side=side,
|
|
1242
|
+
qty=qty,
|
|
1243
|
+
state=STATE_SUBMITTED,
|
|
1244
|
+
intent_key=intent_key,
|
|
1245
|
+
pine_entry_id=pine_entry_id,
|
|
1246
|
+
from_entry=from_entry,
|
|
1247
|
+
extras=extras,
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def mark_modify_completed(
|
|
1252
|
+
store: 'RunContext',
|
|
1253
|
+
*,
|
|
1254
|
+
coid: str,
|
|
1255
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1256
|
+
) -> None:
|
|
1257
|
+
"""Finalise a modify dispatch row's state to ``confirmed``.
|
|
1258
|
+
|
|
1259
|
+
Both :data:`KIND_MODIFY_ENTRY` and :data:`KIND_MODIFY_EXIT` use
|
|
1260
|
+
this terminal helper. The target order it amended is untouched;
|
|
1261
|
+
only the modify command row transitions. Does **not** call
|
|
1262
|
+
:meth:`RunContext.close_order` — see :func:`mark_close_completed`
|
|
1263
|
+
for the rationale.
|
|
1264
|
+
|
|
1265
|
+
:param store: The active run context.
|
|
1266
|
+
:param coid: The modify dispatch's COID.
|
|
1267
|
+
:param extra_payload: Optional extras to preserve (e.g. the
|
|
1268
|
+
amended values echoed back from the confirm response).
|
|
1269
|
+
"""
|
|
1270
|
+
fields: dict[str, Any] = {'state': STATE_CONFIRMED}
|
|
1271
|
+
if extra_payload:
|
|
1272
|
+
existing = store.get_order(coid)
|
|
1273
|
+
merged = dict(existing.extras or {}) if existing is not None else {}
|
|
1274
|
+
merged.update(extra_payload)
|
|
1275
|
+
fields['extras'] = merged
|
|
1276
|
+
store.upsert_order(coid, **fields)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
# === Reconcile-path terminal helpers =======================================
|
|
1280
|
+
|
|
1281
|
+
def mark_reconcile_filled(
|
|
1282
|
+
store: 'RunContext',
|
|
1283
|
+
*,
|
|
1284
|
+
coid: str,
|
|
1285
|
+
filled_qty: float,
|
|
1286
|
+
new_state: str,
|
|
1287
|
+
extras_patch: Mapping[str, Any] | None,
|
|
1288
|
+
) -> None:
|
|
1289
|
+
"""Promote a row to ``confirmed`` from a reconcile-path observation.
|
|
1290
|
+
|
|
1291
|
+
Used by :meth:`~pynecore.core.broker.journal.DispatchJournal.apply_reconcile_outcome`
|
|
1292
|
+
for the working→position case: a row sitting in
|
|
1293
|
+
:data:`STATE_SERVER_REF_SEEN` is observed in the live positions
|
|
1294
|
+
snapshot, so the journal records the fill and flips
|
|
1295
|
+
``extras['kind']`` from :data:`ENTRY_KIND_WORKING` to
|
|
1296
|
+
:data:`ENTRY_KIND_POSITION` in a single transaction.
|
|
1297
|
+
|
|
1298
|
+
:param store: The active run context.
|
|
1299
|
+
:param coid: The row's client-order-id.
|
|
1300
|
+
:param filled_qty: Confirmed fill quantity from the snapshot.
|
|
1301
|
+
:param new_state: The state to land in (always :data:`STATE_CONFIRMED`
|
|
1302
|
+
for the current call sites; passed through for clarity).
|
|
1303
|
+
:param extras_patch: Plugin-supplied extras to merge into the row
|
|
1304
|
+
(e.g. ``{'kind': 'position', 'entry_filled_at': now_ts}``).
|
|
1305
|
+
Merged on top of the existing ``extras`` — the journal does not
|
|
1306
|
+
validate the keys.
|
|
1307
|
+
"""
|
|
1308
|
+
fields: dict[str, Any] = {
|
|
1309
|
+
'state': new_state,
|
|
1310
|
+
'filled_qty': filled_qty,
|
|
1311
|
+
}
|
|
1312
|
+
if extras_patch:
|
|
1313
|
+
existing = store.get_order(coid)
|
|
1314
|
+
merged = dict(existing.extras or {}) if existing is not None else {}
|
|
1315
|
+
merged.update(extras_patch)
|
|
1316
|
+
fields['extras'] = merged
|
|
1317
|
+
store.upsert_order(coid, **fields)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def mark_reconcile_terminal_close(
|
|
1321
|
+
store: 'RunContext',
|
|
1322
|
+
*,
|
|
1323
|
+
coid: str,
|
|
1324
|
+
new_state: str,
|
|
1325
|
+
extras_patch: Mapping[str, Any] | None,
|
|
1326
|
+
close_row: bool,
|
|
1327
|
+
) -> None:
|
|
1328
|
+
"""Terminate a row from a reconcile-path observation.
|
|
1329
|
+
|
|
1330
|
+
Used by :meth:`~pynecore.core.broker.journal.DispatchJournal.apply_reconcile_outcome`
|
|
1331
|
+
for every terminal close that originates in the reconciler:
|
|
1332
|
+
bracket sibling retire on mixed-bracket rejection, pending-trail
|
|
1333
|
+
sibling parent-rejection cascade, missing-pending grace expiry,
|
|
1334
|
+
unexpected-cancel cascade, and eager-teardown follow-up after a
|
|
1335
|
+
natural close.
|
|
1336
|
+
|
|
1337
|
+
The state mutation, the optional extras merge, and the
|
|
1338
|
+
:meth:`RunContext.close_order` (when ``close_row`` is ``True``)
|
|
1339
|
+
happen in this single helper; the journal then writes a separate
|
|
1340
|
+
audit event. The split mirrors :func:`mark_confirmed_with_fill`
|
|
1341
|
+
style — terminal facts persisted first, audit logging by the
|
|
1342
|
+
caller.
|
|
1343
|
+
|
|
1344
|
+
:param store: The active run context.
|
|
1345
|
+
:param coid: The row's client-order-id.
|
|
1346
|
+
:param new_state: Terminal state to land in — typically
|
|
1347
|
+
:data:`STATE_REJECTED` for bracket retire / cascade paths.
|
|
1348
|
+
``close_row=True`` is what drops the row from the live set.
|
|
1349
|
+
:param extras_patch: Plugin-supplied extras to merge before the
|
|
1350
|
+
terminal transition (rarely used on this path; usually
|
|
1351
|
+
``None``).
|
|
1352
|
+
:param close_row: When ``True``, also calls
|
|
1353
|
+
:meth:`RunContext.close_order` after the state update — this
|
|
1354
|
+
is the steady-state default for reconcile terminal closures.
|
|
1355
|
+
"""
|
|
1356
|
+
fields: dict[str, Any] = {'state': new_state}
|
|
1357
|
+
if extras_patch:
|
|
1358
|
+
existing = store.get_order(coid)
|
|
1359
|
+
merged = dict(existing.extras or {}) if existing is not None else {}
|
|
1360
|
+
merged.update(extras_patch)
|
|
1361
|
+
fields['extras'] = merged
|
|
1362
|
+
store.upsert_order(coid, **fields)
|
|
1363
|
+
if close_row:
|
|
1364
|
+
store.close_order(coid)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
# === Engine-trigger partial bracket leg helpers ============================
|
|
1368
|
+
|
|
1369
|
+
def create_engine_trigger_partial_leg_row(
|
|
1370
|
+
store: 'RunContext',
|
|
1371
|
+
*,
|
|
1372
|
+
coid: str,
|
|
1373
|
+
symbol: str,
|
|
1374
|
+
side: str,
|
|
1375
|
+
qty: float,
|
|
1376
|
+
intent_key: str,
|
|
1377
|
+
pine_entry_id: str,
|
|
1378
|
+
from_entry: str,
|
|
1379
|
+
leg_kind: str,
|
|
1380
|
+
leg_state: str,
|
|
1381
|
+
parent_pine_entry_id: str,
|
|
1382
|
+
parent_entry_dispatch_ref: str,
|
|
1383
|
+
intent_partial_qty: float,
|
|
1384
|
+
trigger_level: float | None = None,
|
|
1385
|
+
trigger_offset: float | None = None,
|
|
1386
|
+
trail_activation_level: float | None = None,
|
|
1387
|
+
trail_activation_offset: float | None = None,
|
|
1388
|
+
oca_group: str | None = None,
|
|
1389
|
+
oca_type: str | None = None,
|
|
1390
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1391
|
+
) -> None:
|
|
1392
|
+
"""Persist one engine-trigger partial bracket leg row.
|
|
1393
|
+
|
|
1394
|
+
The row carries no exchange-side order — the engine state machine
|
|
1395
|
+
in :class:`~pynecore.core.broker.software_partial_bracket_engine.SoftwarePartialBracketEngine`
|
|
1396
|
+
owns it. ``orders.state`` is set to :data:`STATE_PARTIAL_BRACKET_LEG`
|
|
1397
|
+
so the journal / reconcile paths short-circuit on these rows; the
|
|
1398
|
+
actual engine-trigger phase lives under
|
|
1399
|
+
:data:`EXTRAS_KEY_LEG_STATE` and is updated via
|
|
1400
|
+
:func:`update_engine_trigger_partial_leg_state`.
|
|
1401
|
+
|
|
1402
|
+
The function deliberately mirrors :func:`create_entry_order_row`'s
|
|
1403
|
+
"insert-only" shape — the row is freshly written by the dispatch
|
|
1404
|
+
path and never re-inserted by the same call site.
|
|
1405
|
+
|
|
1406
|
+
:param store: The active run context.
|
|
1407
|
+
:param coid: The leg's canonical client-order-id. The engine
|
|
1408
|
+
constructs this deterministically from ``(parent_dispatch_ref,
|
|
1409
|
+
leg_kind)`` so a restart-mid-dispatch never produces two
|
|
1410
|
+
rows for the same leg.
|
|
1411
|
+
:param symbol: Exchange-side symbol.
|
|
1412
|
+
:param side: ``'buy'`` / ``'sell'`` — the CLOSE side, opposite the
|
|
1413
|
+
parent position direction.
|
|
1414
|
+
:param qty: Partial-close quantity, already quantized to the
|
|
1415
|
+
broker's lot step.
|
|
1416
|
+
:param intent_key: The owning :class:`ExitIntent`'s diff key.
|
|
1417
|
+
:param pine_entry_id: Pine ``strategy.exit(id=...)`` value, kept
|
|
1418
|
+
for audit symmetry with non-leg rows.
|
|
1419
|
+
:param from_entry: Pine ``strategy.exit(from_entry=...)`` — the
|
|
1420
|
+
parent entry id the leg attaches to.
|
|
1421
|
+
:param leg_kind: One of :data:`LEG_KIND_TP_PARTIAL`,
|
|
1422
|
+
:data:`LEG_KIND_SL_PARTIAL`, :data:`LEG_KIND_TRAIL_PARTIAL`.
|
|
1423
|
+
:param leg_state: Initial state — either :data:`LEG_STATE_ARMED`
|
|
1424
|
+
(entry already filled, absolute trigger level known) or
|
|
1425
|
+
:data:`LEG_STATE_PENDING_ENTRY` (entry pending, tick offsets
|
|
1426
|
+
not yet resolved).
|
|
1427
|
+
:param parent_pine_entry_id: Same as ``from_entry`` — kept
|
|
1428
|
+
separately for clarity at recovery time.
|
|
1429
|
+
:param parent_entry_dispatch_ref: The parent entry row's
|
|
1430
|
+
``client_order_id``. Used by reconciliation to guard against
|
|
1431
|
+
a stale leg attaching to a new parent that happens to share
|
|
1432
|
+
the same Pine entry id (the unique COID prevents that
|
|
1433
|
+
confusion).
|
|
1434
|
+
:param intent_partial_qty: Original :class:`ExitIntent.qty` —
|
|
1435
|
+
kept separately from ``qty`` (which is the dispatched close
|
|
1436
|
+
quantity) so the safety check can compare intent vs. live
|
|
1437
|
+
parent qty at trigger time.
|
|
1438
|
+
:param trigger_level: Absolute price the engine watches for to
|
|
1439
|
+
fire the leg. ``None`` while the leg is ``pending_entry``.
|
|
1440
|
+
:param trigger_offset: Trail offset (price units, not ticks) for
|
|
1441
|
+
:data:`LEG_KIND_TRAIL_PARTIAL`. ``None`` for tp / sl legs.
|
|
1442
|
+
:param trail_activation_level: Absolute activation price for a
|
|
1443
|
+
:data:`LEG_KIND_TRAIL_PARTIAL` leg. ``None`` for tp / sl legs
|
|
1444
|
+
or while the leg is ``pending_entry``.
|
|
1445
|
+
:param trail_activation_offset: Activation offset (price units) for
|
|
1446
|
+
a trail leg, resolved to an absolute level once the parent fill
|
|
1447
|
+
price is known. ``None`` for tp / sl legs.
|
|
1448
|
+
:param oca_group: OCA group name the cascade-cancel keys on.
|
|
1449
|
+
:param oca_type: OCA group type (``'cancel'`` for partial
|
|
1450
|
+
brackets — TP filling cancels the SL and vice versa).
|
|
1451
|
+
:param extra_payload: Additional plugin-specific extras to merge
|
|
1452
|
+
into the row's ``extras``. Useful for journal anchors.
|
|
1453
|
+
:raises ValueError: When ``leg_kind`` is not one of the three
|
|
1454
|
+
canonical values, or ``leg_state`` is not ``armed`` /
|
|
1455
|
+
``pending_entry``.
|
|
1456
|
+
"""
|
|
1457
|
+
if leg_kind not in ENGINE_TRIGGER_LEG_KINDS:
|
|
1458
|
+
raise ValueError(
|
|
1459
|
+
f"create_engine_trigger_partial_leg_row: leg_kind must be "
|
|
1460
|
+
f"one of {sorted(ENGINE_TRIGGER_LEG_KINDS)}, got {leg_kind!r}"
|
|
1461
|
+
)
|
|
1462
|
+
if leg_state not in (LEG_STATE_ARMED, LEG_STATE_PENDING_ENTRY):
|
|
1463
|
+
raise ValueError(
|
|
1464
|
+
f"create_engine_trigger_partial_leg_row: leg_state must be "
|
|
1465
|
+
f"{LEG_STATE_ARMED!r} or {LEG_STATE_PENDING_ENTRY!r}, "
|
|
1466
|
+
f"got {leg_state!r}"
|
|
1467
|
+
)
|
|
1468
|
+
extras: dict[str, Any] = {
|
|
1469
|
+
EXTRAS_KEY_LEG_KIND: leg_kind,
|
|
1470
|
+
EXTRAS_KEY_LEG_STATE: leg_state,
|
|
1471
|
+
EXTRAS_KEY_TRIGGER_LEVEL: trigger_level,
|
|
1472
|
+
EXTRAS_KEY_TRIGGER_OFFSET: trigger_offset,
|
|
1473
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_LEVEL: trail_activation_level,
|
|
1474
|
+
EXTRAS_KEY_TRAIL_ACTIVATION_OFFSET: trail_activation_offset,
|
|
1475
|
+
EXTRAS_KEY_PARENT_PINE_ENTRY_ID: parent_pine_entry_id,
|
|
1476
|
+
EXTRAS_KEY_PARENT_ENTRY_DISPATCH_REF: parent_entry_dispatch_ref,
|
|
1477
|
+
EXTRAS_KEY_INTENT_PARTIAL_QTY: intent_partial_qty,
|
|
1478
|
+
EXTRAS_KEY_OCA_GROUP: oca_group,
|
|
1479
|
+
EXTRAS_KEY_OCA_TYPE: oca_type,
|
|
1480
|
+
}
|
|
1481
|
+
if extra_payload:
|
|
1482
|
+
extras.update(extra_payload)
|
|
1483
|
+
# Same-bar modify/cancel→recreate path: the prior leg row was
|
|
1484
|
+
# closed by ``_dispatch_modify`` (which calls
|
|
1485
|
+
# :meth:`RunContext.close_order` on the legs via
|
|
1486
|
+
# :func:`update_engine_trigger_partial_leg_state` →
|
|
1487
|
+
# ``cancel_legs_for_intent``). The fresh dispatch re-uses the
|
|
1488
|
+
# same bar timestamp and retry sequence and therefore reaches
|
|
1489
|
+
# this helper with the SAME ``coid``. ``upsert_order`` does not
|
|
1490
|
+
# clear ``closed_ts_ms``, so without an explicit reopen the row
|
|
1491
|
+
# stays out of ``iter_live_orders`` / ``iter_active_engine_trigger_partial_legs``
|
|
1492
|
+
# after a restart and the bracket protection disappears. The
|
|
1493
|
+
# call is a no-op when the row does not yet exist or is already
|
|
1494
|
+
# live.
|
|
1495
|
+
store.reopen_order(coid)
|
|
1496
|
+
store.upsert_order(
|
|
1497
|
+
coid,
|
|
1498
|
+
symbol=symbol,
|
|
1499
|
+
side=side,
|
|
1500
|
+
qty=qty,
|
|
1501
|
+
state=STATE_PARTIAL_BRACKET_LEG,
|
|
1502
|
+
intent_key=intent_key,
|
|
1503
|
+
pine_entry_id=pine_entry_id,
|
|
1504
|
+
from_entry=from_entry,
|
|
1505
|
+
extras=extras,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def update_engine_trigger_partial_leg_state(
|
|
1510
|
+
store: 'RunContext',
|
|
1511
|
+
*,
|
|
1512
|
+
coid: str,
|
|
1513
|
+
new_leg_state: str,
|
|
1514
|
+
trigger_level: float | None = None,
|
|
1515
|
+
trigger_offset: float | None = None,
|
|
1516
|
+
qty: float | None = None,
|
|
1517
|
+
extras_patch: Mapping[str, Any] | None = None,
|
|
1518
|
+
close_row: bool | None = None,
|
|
1519
|
+
) -> None:
|
|
1520
|
+
"""Transition an engine-trigger partial bracket leg row.
|
|
1521
|
+
|
|
1522
|
+
Three categories of state move call this helper:
|
|
1523
|
+
|
|
1524
|
+
- ``pending_entry`` → ``armed`` after the parent entry fills (the
|
|
1525
|
+
caller resolves the tick offsets into absolute price levels and
|
|
1526
|
+
passes them via ``trigger_level``).
|
|
1527
|
+
- ``armed`` → ``triggering`` → ``triggered`` / ``triggered_failed``
|
|
1528
|
+
/ ``triggered_unknown`` along the close-dispatch path.
|
|
1529
|
+
- ``armed`` → ``cascaded_cancel`` / ``aborted_*`` along the OCA
|
|
1530
|
+
cascade, parent-gone abort, and broker-native fail-safe paths.
|
|
1531
|
+
|
|
1532
|
+
The function MUST be called with ``close_row=True`` for any
|
|
1533
|
+
transition into :data:`LEG_STATE_TERMINAL` — the helper does NOT
|
|
1534
|
+
infer terminality on its own because some intermediate states
|
|
1535
|
+
(``triggered_failed``, ``triggered_unknown``) look terminal but
|
|
1536
|
+
are retried by the engine in place.
|
|
1537
|
+
|
|
1538
|
+
:param store: The active run context.
|
|
1539
|
+
:param coid: The leg row's client-order-id.
|
|
1540
|
+
:param new_leg_state: Target value for
|
|
1541
|
+
:data:`EXTRAS_KEY_LEG_STATE`. Must be one of the
|
|
1542
|
+
:data:`LEG_STATE_*` constants.
|
|
1543
|
+
:param trigger_level: When non-``None``, overwrites the leg's
|
|
1544
|
+
absolute trigger price (used both for the pending→armed
|
|
1545
|
+
promotion and for trail recompute moves).
|
|
1546
|
+
:param trigger_offset: When non-``None``, overwrites the leg's
|
|
1547
|
+
trail offset (rare; the engine's pricepath is to bump
|
|
1548
|
+
``trigger_level`` instead).
|
|
1549
|
+
:param qty: When non-``None``, overwrites the leg row's ``qty``
|
|
1550
|
+
column. The WATCH-phase safety check may cap the close
|
|
1551
|
+
quantity below the originally-recorded ``intent_partial_qty``
|
|
1552
|
+
when the parent has since been partially reduced; persisting
|
|
1553
|
+
the capped value here keeps ``restart_replay`` from rebuilding
|
|
1554
|
+
the leg with the stale, larger size.
|
|
1555
|
+
:param extras_patch: Additional ``extras`` keys to merge before
|
|
1556
|
+
the upsert — typically empty, used by audit-rich call sites
|
|
1557
|
+
(e.g. failure-reason capture on ``triggered_failed``).
|
|
1558
|
+
:param close_row: When ``True``, also calls
|
|
1559
|
+
:meth:`RunContext.close_order` after the state update. The
|
|
1560
|
+
engine sets this on every transition into
|
|
1561
|
+
:data:`LEG_STATE_TERMINAL`.
|
|
1562
|
+
:raises ValueError: When ``new_leg_state`` is not one of the
|
|
1563
|
+
canonical :data:`LEG_STATE_*` values.
|
|
1564
|
+
"""
|
|
1565
|
+
if new_leg_state not in (LEG_STATE_LIVE | LEG_STATE_TERMINAL):
|
|
1566
|
+
raise ValueError(
|
|
1567
|
+
f"update_engine_trigger_partial_leg_state: new_leg_state must be "
|
|
1568
|
+
f"one of {sorted(LEG_STATE_LIVE | LEG_STATE_TERMINAL)}, "
|
|
1569
|
+
f"got {new_leg_state!r}"
|
|
1570
|
+
)
|
|
1571
|
+
existing = store.get_order(coid)
|
|
1572
|
+
merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
|
|
1573
|
+
merged[EXTRAS_KEY_LEG_STATE] = new_leg_state
|
|
1574
|
+
if trigger_level is not None:
|
|
1575
|
+
merged[EXTRAS_KEY_TRIGGER_LEVEL] = trigger_level
|
|
1576
|
+
if trigger_offset is not None:
|
|
1577
|
+
merged[EXTRAS_KEY_TRIGGER_OFFSET] = trigger_offset
|
|
1578
|
+
if extras_patch:
|
|
1579
|
+
merged.update(extras_patch)
|
|
1580
|
+
upsert_fields: dict[str, Any] = {'extras': merged}
|
|
1581
|
+
if qty is not None:
|
|
1582
|
+
upsert_fields['qty'] = qty
|
|
1583
|
+
store.upsert_order(coid, **upsert_fields)
|
|
1584
|
+
if close_row:
|
|
1585
|
+
store.close_order(coid)
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def iter_active_engine_trigger_partial_legs(
|
|
1589
|
+
store: 'RunContext',
|
|
1590
|
+
*,
|
|
1591
|
+
symbol: str | None = None,
|
|
1592
|
+
from_entry: str | None = None,
|
|
1593
|
+
) -> Iterator['OrderRow']:
|
|
1594
|
+
"""Iterate live engine-trigger partial bracket leg rows.
|
|
1595
|
+
|
|
1596
|
+
Used by :meth:`SoftwarePartialBracketEngine.restart_replay` to
|
|
1597
|
+
rebuild the in-memory leg ledger after a runner restart, and by
|
|
1598
|
+
the cascade-cancel paths that need every leg attached to one
|
|
1599
|
+
parent. Returns rows whose ``orders.state`` is
|
|
1600
|
+
:data:`STATE_PARTIAL_BRACKET_LEG`, are not closed
|
|
1601
|
+
(``closed_ts_ms IS NULL``), and whose
|
|
1602
|
+
:data:`EXTRAS_KEY_LEG_STATE` is one of :data:`LEG_STATE_LIVE`
|
|
1603
|
+
(terminal rows are filtered out even when ``close_order`` has
|
|
1604
|
+
not yet flipped ``closed_ts_ms``). :data:`LEG_STATE_LIVE` includes
|
|
1605
|
+
both engine-owned active states and the cancel-tentative
|
|
1606
|
+
verification state.
|
|
1607
|
+
|
|
1608
|
+
:param store: The active run context.
|
|
1609
|
+
:param symbol: Optional symbol filter — passed straight through
|
|
1610
|
+
to :meth:`RunContext.iter_live_orders`.
|
|
1611
|
+
:param from_entry: Optional Pine parent entry id filter — same
|
|
1612
|
+
passthrough; the cascade-cancel callers use this to target
|
|
1613
|
+
all legs of one parent.
|
|
1614
|
+
"""
|
|
1615
|
+
for row in store.iter_live_orders(symbol=symbol, from_entry=from_entry):
|
|
1616
|
+
if row.state != STATE_PARTIAL_BRACKET_LEG:
|
|
1617
|
+
continue
|
|
1618
|
+
leg_state = (row.extras or {}).get(EXTRAS_KEY_LEG_STATE)
|
|
1619
|
+
if leg_state not in LEG_STATE_LIVE:
|
|
1620
|
+
continue
|
|
1621
|
+
yield row
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
# === One-way emulation close-leg helpers ===================================
|
|
1625
|
+
|
|
1626
|
+
def create_close_leg_row(
|
|
1627
|
+
store: 'RunContext',
|
|
1628
|
+
*,
|
|
1629
|
+
coid: str,
|
|
1630
|
+
symbol: str,
|
|
1631
|
+
side: str,
|
|
1632
|
+
qty: float,
|
|
1633
|
+
intent_key: str,
|
|
1634
|
+
pine_entry_id: str,
|
|
1635
|
+
parent_close_coid: str,
|
|
1636
|
+
leg_id: str,
|
|
1637
|
+
leg_volume: int,
|
|
1638
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1639
|
+
) -> None:
|
|
1640
|
+
"""Persist one one-way emulation close-leg row (PERSIST-FIRST).
|
|
1641
|
+
|
|
1642
|
+
Written by :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator`
|
|
1643
|
+
BEFORE it dispatches the leg's close, so a crash mid-fan-out leaves a durable
|
|
1644
|
+
record of which legs were owed. The row carries no exchange order — its
|
|
1645
|
+
``orders.state`` is :data:`STATE_CLOSE_LEG` so the journal / reconcile paths
|
|
1646
|
+
short-circuit on it; the leg's close FILL arrives on the normal order-event
|
|
1647
|
+
stream and is applied FIFO by ``record_fill`` as a ``LegType.CLOSE``.
|
|
1648
|
+
|
|
1649
|
+
The ``coid`` is the parent CloseIntent's coid suffixed with the broker leg id
|
|
1650
|
+
(``f"{parent_close_coid}:{leg_id}"``), so a restart that re-runs the same
|
|
1651
|
+
dispatch upserts the SAME row rather than duplicating it.
|
|
1652
|
+
|
|
1653
|
+
:param store: The active run context.
|
|
1654
|
+
:param coid: The leg row's deterministic client-order-id.
|
|
1655
|
+
:param symbol: Exchange-side symbol.
|
|
1656
|
+
:param side: The close side (opposite the position direction being reduced).
|
|
1657
|
+
:param qty: The Pine-unit quantity this leg closes (its FIFO slice).
|
|
1658
|
+
:param intent_key: The owning :class:`CloseIntent`'s diff key.
|
|
1659
|
+
:param pine_entry_id: The owning ``strategy.close(id=...)`` value, for audit.
|
|
1660
|
+
:param parent_close_coid: The CloseIntent dispatch's ``KIND_CLOSE`` coid.
|
|
1661
|
+
:param leg_id: Broker leg id (cTrader ``positionId``) this row closes.
|
|
1662
|
+
:param leg_volume: Broker-grid integer volume dispatched for the leg.
|
|
1663
|
+
:param extra_payload: Additional plugin-specific extras to merge.
|
|
1664
|
+
"""
|
|
1665
|
+
extras: dict[str, Any] = {
|
|
1666
|
+
EXTRAS_KEY_CLOSE_LEG_STATE: CLOSE_LEG_STATE_PENDING,
|
|
1667
|
+
EXTRAS_KEY_CLOSE_LEG_ID: leg_id,
|
|
1668
|
+
EXTRAS_KEY_CLOSE_PARENT_COID: parent_close_coid,
|
|
1669
|
+
EXTRAS_KEY_CLOSE_LEG_VOLUME: leg_volume,
|
|
1670
|
+
}
|
|
1671
|
+
if extra_payload:
|
|
1672
|
+
extras.update(extra_payload)
|
|
1673
|
+
# A restart-mid-fan re-dispatch reaches this helper with the SAME derived
|
|
1674
|
+
# coid; reopen so the re-persisted row rejoins the live set (upsert_order
|
|
1675
|
+
# does not clear ``closed_ts_ms`` on its own). No-op when the row is absent
|
|
1676
|
+
# or already live.
|
|
1677
|
+
store.reopen_order(coid)
|
|
1678
|
+
store.upsert_order(
|
|
1679
|
+
coid,
|
|
1680
|
+
symbol=symbol,
|
|
1681
|
+
side=side,
|
|
1682
|
+
qty=qty,
|
|
1683
|
+
state=STATE_CLOSE_LEG,
|
|
1684
|
+
intent_key=intent_key,
|
|
1685
|
+
pine_entry_id=pine_entry_id,
|
|
1686
|
+
extras=extras,
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def update_close_leg_state(
|
|
1691
|
+
store: 'RunContext',
|
|
1692
|
+
*,
|
|
1693
|
+
coid: str,
|
|
1694
|
+
new_state: str,
|
|
1695
|
+
close_row: bool = False,
|
|
1696
|
+
extras_patch: Mapping[str, Any] | None = None,
|
|
1697
|
+
) -> None:
|
|
1698
|
+
"""Transition a close-leg row's :data:`EXTRAS_KEY_CLOSE_LEG_STATE`.
|
|
1699
|
+
|
|
1700
|
+
Set ``close_row=True`` for the terminal :data:`CLOSE_LEG_STATE_DISPATCHED`
|
|
1701
|
+
transition (the per-leg close acked), which also calls
|
|
1702
|
+
:meth:`RunContext.close_order` so the row leaves
|
|
1703
|
+
:func:`iter_active_close_legs`'s live set.
|
|
1704
|
+
|
|
1705
|
+
:param store: The active run context.
|
|
1706
|
+
:param coid: The leg row's client-order-id.
|
|
1707
|
+
:param new_state: Target :data:`CLOSE_LEG_STATE_PENDING` /
|
|
1708
|
+
:data:`CLOSE_LEG_STATE_DISPATCHED`.
|
|
1709
|
+
:param close_row: When ``True``, also close the row (terminal).
|
|
1710
|
+
:param extras_patch: Additional extras to merge before the upsert.
|
|
1711
|
+
:raises ValueError: When ``new_state`` is not a canonical close-leg state.
|
|
1712
|
+
"""
|
|
1713
|
+
if new_state not in (CLOSE_LEG_STATE_PENDING, CLOSE_LEG_STATE_DISPATCHED):
|
|
1714
|
+
raise ValueError(
|
|
1715
|
+
f"update_close_leg_state: new_state must be one of "
|
|
1716
|
+
f"{{CLOSE_LEG_STATE_PENDING, CLOSE_LEG_STATE_DISPATCHED}}, "
|
|
1717
|
+
f"got {new_state!r}"
|
|
1718
|
+
)
|
|
1719
|
+
existing = store.get_order(coid)
|
|
1720
|
+
merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
|
|
1721
|
+
merged[EXTRAS_KEY_CLOSE_LEG_STATE] = new_state
|
|
1722
|
+
if extras_patch:
|
|
1723
|
+
merged.update(extras_patch)
|
|
1724
|
+
store.upsert_order(coid, extras=merged)
|
|
1725
|
+
if close_row:
|
|
1726
|
+
store.close_order(coid)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def iter_active_close_legs(
|
|
1730
|
+
store: 'RunContext',
|
|
1731
|
+
*,
|
|
1732
|
+
symbol: str | None = None,
|
|
1733
|
+
) -> Iterator['OrderRow']:
|
|
1734
|
+
"""Iterate live (pending) close-leg rows for restart replay.
|
|
1735
|
+
|
|
1736
|
+
Returns rows whose ``orders.state`` is :data:`STATE_CLOSE_LEG`, are not
|
|
1737
|
+
closed, and whose :data:`EXTRAS_KEY_CLOSE_LEG_STATE` is in
|
|
1738
|
+
:data:`CLOSE_LEG_STATE_LIVE` — a fan-out leg whose close was persisted but
|
|
1739
|
+
never acked. :meth:`OneWayEmulator.restart_replay` reconciles each against
|
|
1740
|
+
the live broker legs before re-dispatching.
|
|
1741
|
+
|
|
1742
|
+
:param store: The active run context.
|
|
1743
|
+
:param symbol: Optional symbol filter, passed to
|
|
1744
|
+
:meth:`RunContext.iter_live_orders`.
|
|
1745
|
+
"""
|
|
1746
|
+
for row in store.iter_live_orders(symbol=symbol):
|
|
1747
|
+
if row.state != STATE_CLOSE_LEG:
|
|
1748
|
+
continue
|
|
1749
|
+
leg_state = (row.extras or {}).get(EXTRAS_KEY_CLOSE_LEG_STATE)
|
|
1750
|
+
if leg_state not in CLOSE_LEG_STATE_LIVE:
|
|
1751
|
+
continue
|
|
1752
|
+
yield row
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
# === One-way emulation residual-open helpers ===============================
|
|
1756
|
+
|
|
1757
|
+
def create_residual_open_row(
|
|
1758
|
+
store: 'RunContext',
|
|
1759
|
+
*,
|
|
1760
|
+
coid: str,
|
|
1761
|
+
symbol: str,
|
|
1762
|
+
side: str,
|
|
1763
|
+
qty: float,
|
|
1764
|
+
intent_key: str,
|
|
1765
|
+
pine_entry_id: str,
|
|
1766
|
+
entry_coid: str,
|
|
1767
|
+
run_tag: str,
|
|
1768
|
+
bar_ts_ms: int,
|
|
1769
|
+
retry_seq: int,
|
|
1770
|
+
) -> None:
|
|
1771
|
+
"""Persist a reversal's residual-OPEN breadcrumb (PERSIST-FIRST).
|
|
1772
|
+
|
|
1773
|
+
Written by :meth:`~pynecore.core.broker.one_way_emulator.OneWayEmulator.run_reversal`
|
|
1774
|
+
BEFORE the FIFO closes, so a crash in the window between the closes landing
|
|
1775
|
+
and the residual :meth:`PositionPort.place_leg` persisting its own entry row
|
|
1776
|
+
leaves a durable record that an open is owed. The row owns no exchange order —
|
|
1777
|
+
its ``orders.state`` is :data:`STATE_RESIDUAL_OPEN`, so the journal /
|
|
1778
|
+
reconcile / startup-recovery paths short-circuit on it.
|
|
1779
|
+
:meth:`OneWayEmulator.restart_replay` reconciles it against the residual entry
|
|
1780
|
+
row (keyed on ``entry_coid``) and re-dispatches the open only when that row
|
|
1781
|
+
never landed, then clears this breadcrumb.
|
|
1782
|
+
|
|
1783
|
+
The ``coid`` is the reversal's ``KIND_CLOSE`` parent coid suffixed
|
|
1784
|
+
``":residual"``, so a restart re-run upserts the SAME row.
|
|
1785
|
+
|
|
1786
|
+
:param store: The active run context.
|
|
1787
|
+
:param coid: The breadcrumb row's deterministic client-order-id.
|
|
1788
|
+
:param symbol: Exchange-side symbol.
|
|
1789
|
+
:param side: The residual open's side (the entry's own direction).
|
|
1790
|
+
:param qty: The residual open quantity (Pine units).
|
|
1791
|
+
:param intent_key: The owning :class:`EntryIntent`'s diff key.
|
|
1792
|
+
:param pine_entry_id: The owning ``strategy.entry(id=...)`` value.
|
|
1793
|
+
:param entry_coid: The residual ``place_leg``'s deterministic entry coid
|
|
1794
|
+
(``KIND_ENTRY``, or ``KIND_ENTRY_STOP`` for a stop-fired reversal) —
|
|
1795
|
+
its presence on replay proves the open already persisted.
|
|
1796
|
+
:param run_tag: Dispatch envelope run tag (to rebuild the envelope on replay).
|
|
1797
|
+
:param bar_ts_ms: Dispatch envelope bar timestamp.
|
|
1798
|
+
:param retry_seq: Dispatch envelope retry sequence.
|
|
1799
|
+
"""
|
|
1800
|
+
extras: dict[str, Any] = {
|
|
1801
|
+
EXTRAS_KEY_RESIDUAL_OPEN_ENTRY_COID: entry_coid,
|
|
1802
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RUN_TAG: run_tag,
|
|
1803
|
+
EXTRAS_KEY_RESIDUAL_OPEN_BAR_TS_MS: bar_ts_ms,
|
|
1804
|
+
EXTRAS_KEY_RESIDUAL_OPEN_RETRY_SEQ: retry_seq,
|
|
1805
|
+
}
|
|
1806
|
+
store.reopen_order(coid)
|
|
1807
|
+
store.upsert_order(
|
|
1808
|
+
coid,
|
|
1809
|
+
symbol=symbol,
|
|
1810
|
+
side=side,
|
|
1811
|
+
qty=qty,
|
|
1812
|
+
state=STATE_RESIDUAL_OPEN,
|
|
1813
|
+
intent_key=intent_key,
|
|
1814
|
+
pine_entry_id=pine_entry_id,
|
|
1815
|
+
extras=extras,
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
def clear_residual_open_row(store: 'RunContext', coid: str) -> None:
|
|
1820
|
+
"""Finalise a residual-open breadcrumb once the open is durable / replayed."""
|
|
1821
|
+
store.close_order(coid)
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def iter_active_residual_opens(
|
|
1825
|
+
store: 'RunContext',
|
|
1826
|
+
*,
|
|
1827
|
+
symbol: str | None = None,
|
|
1828
|
+
) -> Iterator['OrderRow']:
|
|
1829
|
+
"""Iterate live residual-open breadcrumbs for restart replay.
|
|
1830
|
+
|
|
1831
|
+
Returns rows whose ``orders.state`` is :data:`STATE_RESIDUAL_OPEN` and which
|
|
1832
|
+
are not yet closed — a reversal whose residual open was recorded but whose
|
|
1833
|
+
durability (its own entry row) the crash may have pre-empted.
|
|
1834
|
+
|
|
1835
|
+
:param store: The active run context.
|
|
1836
|
+
:param symbol: Optional symbol filter, passed to
|
|
1837
|
+
:meth:`RunContext.iter_live_orders`.
|
|
1838
|
+
"""
|
|
1839
|
+
for row in store.iter_live_orders(symbol=symbol):
|
|
1840
|
+
if row.state == STATE_RESIDUAL_OPEN:
|
|
1841
|
+
yield row
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
# === One-way emulation bracket-ownership helpers ===========================
|
|
1845
|
+
|
|
1846
|
+
def create_bracket_ownership_row(
|
|
1847
|
+
store: 'RunContext',
|
|
1848
|
+
*,
|
|
1849
|
+
coid: str,
|
|
1850
|
+
symbol: str,
|
|
1851
|
+
side: str,
|
|
1852
|
+
qty: float,
|
|
1853
|
+
intent_key: str,
|
|
1854
|
+
pine_entry_id: str,
|
|
1855
|
+
from_entry: str,
|
|
1856
|
+
leg_id: str,
|
|
1857
|
+
attach_coid: str,
|
|
1858
|
+
tp_price: float | None,
|
|
1859
|
+
sl_price: float | None,
|
|
1860
|
+
trail_price: float | None,
|
|
1861
|
+
trail_offset: float | None,
|
|
1862
|
+
oca_name: str | None = None,
|
|
1863
|
+
oca_type: str | None = None,
|
|
1864
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
1865
|
+
) -> None:
|
|
1866
|
+
"""Persist one per-leg bracket-ownership row (PERSIST-FIRST).
|
|
1867
|
+
|
|
1868
|
+
Written by :class:`~pynecore.core.broker.one_way_emulator.OneWayEmulator`
|
|
1869
|
+
BEFORE it amends an exit's TP/SL/trailing bracket onto one hedging leg, so a
|
|
1870
|
+
crash mid-replication leaves a durable record of exactly which legs this exit
|
|
1871
|
+
protects. The row carries no exchange order — its ``orders.state`` is
|
|
1872
|
+
:data:`STATE_BRACKET_OWN` so the journal / reconcile paths short-circuit; the
|
|
1873
|
+
leg's protective FILL (if it fires) arrives on the normal order-event stream.
|
|
1874
|
+
|
|
1875
|
+
The ``coid`` is the exit's bracket-attach coid suffixed with the broker leg
|
|
1876
|
+
id (``f"{attach_coid}:{leg_id}"``), so a re-attach upserts the SAME row and
|
|
1877
|
+
two different exits (distinct attach coids) own disjoint coid namespaces. The
|
|
1878
|
+
``from_entry`` is written to its own column so the clear path can pre-filter
|
|
1879
|
+
the live set cheaply via :meth:`RunContext.iter_live_orders`.
|
|
1880
|
+
|
|
1881
|
+
:param store: The active run context.
|
|
1882
|
+
:param coid: The ownership row's deterministic client-order-id.
|
|
1883
|
+
:param symbol: Exchange-side symbol.
|
|
1884
|
+
:param side: The exit (bracket) side.
|
|
1885
|
+
:param qty: The owning :class:`ExitIntent`'s quantity, for audit.
|
|
1886
|
+
:param intent_key: The owning :class:`ExitIntent`'s diff key
|
|
1887
|
+
(``f"{pine_id}\\0{from_entry}"``), matched by the clear path.
|
|
1888
|
+
:param pine_entry_id: The owning ``strategy.exit(id=...)`` value.
|
|
1889
|
+
:param from_entry: The exit's parent entry id (its own column for filtering).
|
|
1890
|
+
:param leg_id: Broker leg id (cTrader ``positionId``) the bracket is on.
|
|
1891
|
+
:param attach_coid: The exit's ``KIND_EXIT_SL`` bracket-attach coid.
|
|
1892
|
+
:param tp_price: Pine-unit take-profit level last replicated (or ``None``).
|
|
1893
|
+
:param sl_price: Pine-unit stop-loss level last replicated (or ``None``).
|
|
1894
|
+
:param trail_price: Pine-unit trailing-stop activation price (or ``None``).
|
|
1895
|
+
:param trail_offset: Pine-unit trailing offset last replicated (or ``None``).
|
|
1896
|
+
:param oca_name: The owning exit's OCA group name (or ``None``).
|
|
1897
|
+
:param oca_type: The owning exit's OCA type string (``"reduce"`` /
|
|
1898
|
+
``"cancel"`` / ``"none"``, or ``None``).
|
|
1899
|
+
:param extra_payload: Additional plugin-specific extras to merge.
|
|
1900
|
+
"""
|
|
1901
|
+
extras: dict[str, Any] = {
|
|
1902
|
+
EXTRAS_KEY_BRACKET_OWN_STATE: BRACKET_OWN_STATE_ACTIVE,
|
|
1903
|
+
EXTRAS_KEY_BRACKET_OWN_LEG_ID: leg_id,
|
|
1904
|
+
EXTRAS_KEY_BRACKET_OWN_ATTACH_COID: attach_coid,
|
|
1905
|
+
EXTRAS_KEY_BRACKET_OWN_TP: tp_price,
|
|
1906
|
+
EXTRAS_KEY_BRACKET_OWN_SL: sl_price,
|
|
1907
|
+
EXTRAS_KEY_BRACKET_OWN_TRAIL_PRICE: trail_price,
|
|
1908
|
+
EXTRAS_KEY_BRACKET_OWN_TRAIL_OFFSET: trail_offset,
|
|
1909
|
+
EXTRAS_KEY_BRACKET_OWN_OCA_NAME: oca_name,
|
|
1910
|
+
EXTRAS_KEY_BRACKET_OWN_OCA_TYPE: oca_type,
|
|
1911
|
+
}
|
|
1912
|
+
if extra_payload:
|
|
1913
|
+
extras.update(extra_payload)
|
|
1914
|
+
# A re-attach (modify, or restart re-assert) reaches this helper with the
|
|
1915
|
+
# SAME derived coid; reopen so the re-persisted row rejoins the live set
|
|
1916
|
+
# (upsert_order does not clear ``closed_ts_ms`` on its own). No-op when the
|
|
1917
|
+
# row is absent or already live.
|
|
1918
|
+
store.reopen_order(coid)
|
|
1919
|
+
store.upsert_order(
|
|
1920
|
+
coid,
|
|
1921
|
+
symbol=symbol,
|
|
1922
|
+
side=side,
|
|
1923
|
+
qty=qty,
|
|
1924
|
+
state=STATE_BRACKET_OWN,
|
|
1925
|
+
intent_key=intent_key,
|
|
1926
|
+
pine_entry_id=pine_entry_id,
|
|
1927
|
+
from_entry=from_entry,
|
|
1928
|
+
extras=extras,
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def update_bracket_ownership_state(
|
|
1933
|
+
store: 'RunContext',
|
|
1934
|
+
*,
|
|
1935
|
+
coid: str,
|
|
1936
|
+
new_state: str,
|
|
1937
|
+
close_row: bool = False,
|
|
1938
|
+
extras_patch: Mapping[str, Any] | None = None,
|
|
1939
|
+
) -> None:
|
|
1940
|
+
"""Transition a bracket-ownership row's :data:`EXTRAS_KEY_BRACKET_OWN_STATE`.
|
|
1941
|
+
|
|
1942
|
+
Set ``close_row=True`` for the terminal :data:`BRACKET_OWN_STATE_RELEASED`
|
|
1943
|
+
transition (the bracket cleared from this leg), which also calls
|
|
1944
|
+
:meth:`RunContext.close_order` so the row leaves
|
|
1945
|
+
:func:`iter_active_bracket_ownerships`'s live set.
|
|
1946
|
+
|
|
1947
|
+
:param store: The active run context.
|
|
1948
|
+
:param coid: The ownership row's client-order-id.
|
|
1949
|
+
:param new_state: Target :data:`BRACKET_OWN_STATE_ACTIVE` /
|
|
1950
|
+
:data:`BRACKET_OWN_STATE_CLEARING` / :data:`BRACKET_OWN_STATE_RELEASED`.
|
|
1951
|
+
:param close_row: When ``True``, also close the row (terminal).
|
|
1952
|
+
:param extras_patch: Additional extras to merge before the upsert.
|
|
1953
|
+
:raises ValueError: When ``new_state`` is not a canonical ownership state.
|
|
1954
|
+
"""
|
|
1955
|
+
if new_state not in (
|
|
1956
|
+
BRACKET_OWN_STATE_ACTIVE,
|
|
1957
|
+
BRACKET_OWN_STATE_CLEARING,
|
|
1958
|
+
BRACKET_OWN_STATE_RELEASED,
|
|
1959
|
+
):
|
|
1960
|
+
raise ValueError(
|
|
1961
|
+
f"update_bracket_ownership_state: new_state must be one of "
|
|
1962
|
+
f"{{BRACKET_OWN_STATE_ACTIVE, BRACKET_OWN_STATE_CLEARING, "
|
|
1963
|
+
f"BRACKET_OWN_STATE_RELEASED}}, got {new_state!r}"
|
|
1964
|
+
)
|
|
1965
|
+
existing = store.get_order(coid)
|
|
1966
|
+
merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
|
|
1967
|
+
merged[EXTRAS_KEY_BRACKET_OWN_STATE] = new_state
|
|
1968
|
+
if extras_patch:
|
|
1969
|
+
merged.update(extras_patch)
|
|
1970
|
+
store.upsert_order(coid, extras=merged)
|
|
1971
|
+
if close_row:
|
|
1972
|
+
store.close_order(coid)
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def iter_active_bracket_ownerships(
|
|
1976
|
+
store: 'RunContext',
|
|
1977
|
+
*,
|
|
1978
|
+
symbol: str | None = None,
|
|
1979
|
+
from_entry: str | None = None,
|
|
1980
|
+
) -> Iterator['OrderRow']:
|
|
1981
|
+
"""Iterate live (active) bracket-ownership rows.
|
|
1982
|
+
|
|
1983
|
+
Returns rows whose ``orders.state`` is :data:`STATE_BRACKET_OWN`, are not
|
|
1984
|
+
closed, and whose :data:`EXTRAS_KEY_BRACKET_OWN_STATE` is in
|
|
1985
|
+
:data:`BRACKET_OWN_STATE_LIVE`. The clear path filters further by
|
|
1986
|
+
``intent_key`` to touch only the legs a specific exit owns; the restart pass
|
|
1987
|
+
re-asserts every active row against the live broker legs.
|
|
1988
|
+
|
|
1989
|
+
:param store: The active run context.
|
|
1990
|
+
:param symbol: Optional symbol filter, passed to
|
|
1991
|
+
:meth:`RunContext.iter_live_orders`.
|
|
1992
|
+
:param from_entry: Optional parent-entry filter, passed to
|
|
1993
|
+
:meth:`RunContext.iter_live_orders` (the indexed ``from_entry`` column).
|
|
1994
|
+
"""
|
|
1995
|
+
for row in store.iter_live_orders(symbol=symbol, from_entry=from_entry):
|
|
1996
|
+
if row.state != STATE_BRACKET_OWN:
|
|
1997
|
+
continue
|
|
1998
|
+
own_state = (row.extras or {}).get(EXTRAS_KEY_BRACKET_OWN_STATE)
|
|
1999
|
+
if own_state not in BRACKET_OWN_STATE_LIVE:
|
|
2000
|
+
continue
|
|
2001
|
+
yield row
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
# === Entry-stop watch helpers ==============================================
|
|
2005
|
+
|
|
2006
|
+
def create_entry_stop_watch_row(
|
|
2007
|
+
store: 'RunContext',
|
|
2008
|
+
*,
|
|
2009
|
+
coid: str,
|
|
2010
|
+
symbol: str,
|
|
2011
|
+
side: str,
|
|
2012
|
+
qty: float,
|
|
2013
|
+
intent_key: str,
|
|
2014
|
+
pine_entry_id: str,
|
|
2015
|
+
stop_level: float,
|
|
2016
|
+
limit_coid: str,
|
|
2017
|
+
entry_stop_state: str = ENTRY_STOP_STATE_ARMED,
|
|
2018
|
+
extra_payload: Mapping[str, Any] | None = None,
|
|
2019
|
+
) -> None:
|
|
2020
|
+
"""Persist one entry-stop watch row (PERSIST-FIRST).
|
|
2021
|
+
|
|
2022
|
+
The row carries no exchange-side order — the
|
|
2023
|
+
:class:`~pynecore.core.broker.software_entry_stop_engine.SoftwareEntryStopEngine`
|
|
2024
|
+
state machine owns it. ``orders.state`` is set to
|
|
2025
|
+
:data:`STATE_ENTRY_STOP_WATCH` so the journal / reconcile paths
|
|
2026
|
+
short-circuit on these rows; the watch phase lives under
|
|
2027
|
+
:data:`EXTRAS_KEY_ENTRY_STOP_STATE` and is advanced via
|
|
2028
|
+
:func:`update_entry_stop_watch_state`.
|
|
2029
|
+
|
|
2030
|
+
:param store: The active run context.
|
|
2031
|
+
:param coid: The watch row's deterministic client-order-id — built from
|
|
2032
|
+
the parent entry envelope with
|
|
2033
|
+
:data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP_WATCH`, so a
|
|
2034
|
+
restart-mid-dispatch never produces two rows for one watch. Distinct
|
|
2035
|
+
from the stop-fired MARKET order's id
|
|
2036
|
+
(:data:`~pynecore.core.broker.idempotency.KIND_ENTRY_STOP`).
|
|
2037
|
+
:param symbol: Exchange-side symbol.
|
|
2038
|
+
:param side: The ENTRY side (``'buy'`` / ``'sell'``) — the direction the
|
|
2039
|
+
stop-fired MARKET opens in, same as the native LIMIT leg.
|
|
2040
|
+
:param qty: Entry quantity, already quantized to the broker's lot step.
|
|
2041
|
+
:param intent_key: The owning :class:`EntryIntent`'s diff key (≡ pine_id).
|
|
2042
|
+
:param pine_entry_id: The Pine ``strategy.entry(id=...)`` value.
|
|
2043
|
+
:param stop_level: Absolute price the watch fires the MARKET at.
|
|
2044
|
+
:param limit_coid: The native LIMIT leg's client-order-id — the
|
|
2045
|
+
leg-scoped cancel target when the stop crosses.
|
|
2046
|
+
:param entry_stop_state: Initial state, normally
|
|
2047
|
+
:data:`ENTRY_STOP_STATE_ARMED`.
|
|
2048
|
+
:param extra_payload: Additional extras to merge into the row.
|
|
2049
|
+
:raises ValueError: When ``entry_stop_state`` is not a live state.
|
|
2050
|
+
"""
|
|
2051
|
+
if entry_stop_state not in ENTRY_STOP_STATE_LIVE:
|
|
2052
|
+
raise ValueError(
|
|
2053
|
+
f"create_entry_stop_watch_row: entry_stop_state must be one of "
|
|
2054
|
+
f"{sorted(ENTRY_STOP_STATE_LIVE)}, got {entry_stop_state!r}"
|
|
2055
|
+
)
|
|
2056
|
+
extras: dict[str, Any] = {
|
|
2057
|
+
EXTRAS_KEY_ENTRY_STOP_STATE: entry_stop_state,
|
|
2058
|
+
EXTRAS_KEY_ENTRY_STOP_LEVEL: stop_level,
|
|
2059
|
+
EXTRAS_KEY_ENTRY_STOP_LIMIT_COID: limit_coid,
|
|
2060
|
+
EXTRAS_KEY_ENTRY_STOP_MARKET_COID: None,
|
|
2061
|
+
}
|
|
2062
|
+
if extra_payload:
|
|
2063
|
+
extras.update(extra_payload)
|
|
2064
|
+
store.reopen_order(coid)
|
|
2065
|
+
store.upsert_order(
|
|
2066
|
+
coid,
|
|
2067
|
+
symbol=symbol,
|
|
2068
|
+
side=side,
|
|
2069
|
+
qty=qty,
|
|
2070
|
+
state=STATE_ENTRY_STOP_WATCH,
|
|
2071
|
+
intent_key=intent_key,
|
|
2072
|
+
pine_entry_id=pine_entry_id,
|
|
2073
|
+
extras=extras,
|
|
2074
|
+
)
|
|
2075
|
+
|
|
2076
|
+
|
|
2077
|
+
def update_entry_stop_watch_state(
|
|
2078
|
+
store: 'RunContext',
|
|
2079
|
+
*,
|
|
2080
|
+
coid: str,
|
|
2081
|
+
new_state: str,
|
|
2082
|
+
market_coid: str | None = None,
|
|
2083
|
+
extras_patch: Mapping[str, Any] | None = None,
|
|
2084
|
+
close_row: bool | None = None,
|
|
2085
|
+
stop_level: float | None = None,
|
|
2086
|
+
qty: float | None = None,
|
|
2087
|
+
side: str | None = None,
|
|
2088
|
+
) -> None:
|
|
2089
|
+
"""Transition an entry-stop watch row.
|
|
2090
|
+
|
|
2091
|
+
:param store: The active run context.
|
|
2092
|
+
:param coid: The watch row's client-order-id.
|
|
2093
|
+
:param new_state: Target :data:`EXTRAS_KEY_ENTRY_STOP_STATE` value.
|
|
2094
|
+
:param market_coid: When non-``None``, persists the stop-fired MARKET
|
|
2095
|
+
order's deterministic client-order-id under
|
|
2096
|
+
:data:`EXTRAS_KEY_ENTRY_STOP_MARKET_COID`. Written on the transition
|
|
2097
|
+
into :data:`ENTRY_STOP_STATE_MARKET_PENDING`, BEFORE the POST, so a
|
|
2098
|
+
restart can verify-before-resend.
|
|
2099
|
+
:param extras_patch: Additional extras keys to merge before the upsert.
|
|
2100
|
+
:param close_row: When ``True``, also calls
|
|
2101
|
+
:meth:`RunContext.close_order` — set on every transition into
|
|
2102
|
+
:data:`ENTRY_STOP_STATE_TERMINAL`.
|
|
2103
|
+
:param stop_level: When non-``None``, overwrites the watch's fire level
|
|
2104
|
+
under :data:`EXTRAS_KEY_ENTRY_STOP_LEVEL`. Used by the modify path to
|
|
2105
|
+
re-sync the software STOP leg to an amended both-set entry.
|
|
2106
|
+
:param qty: When non-``None``, overwrites the watch row's ``qty`` column
|
|
2107
|
+
(the stop-fired MARKET size). Same modify-path use as ``stop_level``.
|
|
2108
|
+
:param side: When non-``None``, overwrites the watch row's ``side`` column
|
|
2109
|
+
(the entry direction). Same modify-path use as ``stop_level``.
|
|
2110
|
+
:raises ValueError: When ``new_state`` is not a canonical value.
|
|
2111
|
+
"""
|
|
2112
|
+
if new_state not in (ENTRY_STOP_STATE_LIVE | ENTRY_STOP_STATE_TERMINAL):
|
|
2113
|
+
raise ValueError(
|
|
2114
|
+
f"update_entry_stop_watch_state: new_state must be one of "
|
|
2115
|
+
f"{sorted(ENTRY_STOP_STATE_LIVE | ENTRY_STOP_STATE_TERMINAL)}, "
|
|
2116
|
+
f"got {new_state!r}"
|
|
2117
|
+
)
|
|
2118
|
+
existing = store.get_order(coid)
|
|
2119
|
+
merged: dict[str, Any] = dict(existing.extras or {}) if existing is not None else {}
|
|
2120
|
+
merged[EXTRAS_KEY_ENTRY_STOP_STATE] = new_state
|
|
2121
|
+
if market_coid is not None:
|
|
2122
|
+
merged[EXTRAS_KEY_ENTRY_STOP_MARKET_COID] = market_coid
|
|
2123
|
+
if stop_level is not None:
|
|
2124
|
+
merged[EXTRAS_KEY_ENTRY_STOP_LEVEL] = stop_level
|
|
2125
|
+
if extras_patch:
|
|
2126
|
+
merged.update(extras_patch)
|
|
2127
|
+
upsert_fields: dict[str, Any] = {'extras': merged}
|
|
2128
|
+
if qty is not None:
|
|
2129
|
+
upsert_fields['qty'] = qty
|
|
2130
|
+
if side is not None:
|
|
2131
|
+
upsert_fields['side'] = side
|
|
2132
|
+
store.upsert_order(coid, **upsert_fields)
|
|
2133
|
+
if close_row:
|
|
2134
|
+
store.close_order(coid)
|
|
2135
|
+
|
|
2136
|
+
|
|
2137
|
+
def iter_active_entry_stop_watches(
|
|
2138
|
+
store: 'RunContext',
|
|
2139
|
+
*,
|
|
2140
|
+
symbol: str | None = None,
|
|
2141
|
+
) -> Iterator['OrderRow']:
|
|
2142
|
+
"""Iterate live entry-stop watch rows.
|
|
2143
|
+
|
|
2144
|
+
Used by :meth:`SoftwareEntryStopEngine.restart_replay` to rebuild the
|
|
2145
|
+
in-memory watch ledger after a runner restart. Returns rows whose
|
|
2146
|
+
``orders.state`` is :data:`STATE_ENTRY_STOP_WATCH`, are not closed, and
|
|
2147
|
+
whose :data:`EXTRAS_KEY_ENTRY_STOP_STATE` is one of
|
|
2148
|
+
:data:`ENTRY_STOP_STATE_LIVE` (terminal rows are filtered out even when
|
|
2149
|
+
``close_order`` has not yet flipped ``closed_ts_ms``).
|
|
2150
|
+
|
|
2151
|
+
:param store: The active run context.
|
|
2152
|
+
:param symbol: Optional symbol filter — passed straight through to
|
|
2153
|
+
:meth:`RunContext.iter_live_orders`.
|
|
2154
|
+
"""
|
|
2155
|
+
for row in store.iter_live_orders(symbol=symbol):
|
|
2156
|
+
if row.state != STATE_ENTRY_STOP_WATCH:
|
|
2157
|
+
continue
|
|
2158
|
+
watch_state = (row.extras or {}).get(EXTRAS_KEY_ENTRY_STOP_STATE)
|
|
2159
|
+
if watch_state not in ENTRY_STOP_STATE_LIVE:
|
|
2160
|
+
continue
|
|
2161
|
+
yield row
|