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,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run identity — logical and physical keys for a broker run.
|
|
3
|
+
|
|
4
|
+
A *run* is one concrete execution of a Pine strategy against a live broker
|
|
5
|
+
account for a specific (symbol, timeframe, optional label) combination.
|
|
6
|
+
Two keys exist for different purposes:
|
|
7
|
+
|
|
8
|
+
- **``run_id``** — human-readable, deterministic stream identifier. The same
|
|
9
|
+
strategy + account + symbol + timeframe + label combination always
|
|
10
|
+
yields the same value. This is the key for historical lookups
|
|
11
|
+
(*"show every past run of this bot"*).
|
|
12
|
+
- **``run_instance_id``** — physical autoincrement INTEGER, unique to each
|
|
13
|
+
process invocation. FK column of every storage table. The storage
|
|
14
|
+
populates it; this module is not concerned with it.
|
|
15
|
+
|
|
16
|
+
``RunIdentity`` is the construction input for both keys: the strategy
|
|
17
|
+
runner assembles it from the CLI + plugin auth, hands it to the storage,
|
|
18
|
+
and gets a ``RunContext`` back with the ``run_instance_id`` attached.
|
|
19
|
+
|
|
20
|
+
Why a separate module from ``idempotency.py``: idempotency owns the
|
|
21
|
+
bit-level shape of ``client_order_id`` (broker protocol), while
|
|
22
|
+
``RunIdentity`` owns run-scoped identity (PyneCore-internal). Two
|
|
23
|
+
distinct abstraction layers — merging them in one module would be
|
|
24
|
+
opaque.
|
|
25
|
+
"""
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
# noinspection PyProtectedMember
|
|
32
|
+
from pynecore.core.broker.idempotency import RUN_TAG_WIDTH, _to_base36
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
'RunIdentity',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# 20 bits → 4 base36 chars, the same range as the original
|
|
39
|
+
# ``make_run_tag``. The wider input only improves the collision space;
|
|
40
|
+
# the output format (``{run_tag}-{pid}-...``) is unchanged.
|
|
41
|
+
_RUN_TAG_BITS: Final[int] = 20
|
|
42
|
+
_RUN_TAG_MASK: Final[int] = (1 << _RUN_TAG_BITS) - 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class RunIdentity:
|
|
47
|
+
"""Identity of a concrete bot run, prior to storage.
|
|
48
|
+
|
|
49
|
+
:param strategy_id: Stem of the script file (e.g. ``"ema_cross"``).
|
|
50
|
+
The logical name of the codebase, NOT a version hash —
|
|
51
|
+
source-code changes are captured by ``run_tag`` via the
|
|
52
|
+
``make_run_tag`` hash input.
|
|
53
|
+
:param symbol: Traded instrument (e.g. ``"EURUSD"``).
|
|
54
|
+
:param timeframe: TradingView format (e.g. ``"60"``, ``"1D"``).
|
|
55
|
+
:param account_id: Plugin-qualified broker-account identifier, e.g.
|
|
56
|
+
``"capitalcom-demo-1234567"``. Provided by the plugin's sync
|
|
57
|
+
``.account_id`` property, populated during authentication.
|
|
58
|
+
Defaults to ``"default"`` when no account is available.
|
|
59
|
+
:param label: Optional user override (CLI ``--run-label``). Used to
|
|
60
|
+
distinguish multiple instances of the same
|
|
61
|
+
(strategy_id, symbol, timeframe, account_id) combination.
|
|
62
|
+
Default: ``None``.
|
|
63
|
+
"""
|
|
64
|
+
strategy_id: str
|
|
65
|
+
symbol: str
|
|
66
|
+
timeframe: str
|
|
67
|
+
account_id: str
|
|
68
|
+
label: str | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def run_id(self) -> str:
|
|
72
|
+
"""Human-readable logical key.
|
|
73
|
+
|
|
74
|
+
Format: ``"{strategy_id}@{account_id}:{symbol}:{timeframe}"`` or,
|
|
75
|
+
when a label is provided, ``"...#{label}"``. A separate ``#``
|
|
76
|
+
separator is required because ``:`` can appear after the
|
|
77
|
+
timeframe (e.g. ``"1D"``); ``#`` on the other hand is not legal
|
|
78
|
+
even inside a label that comes through the CLI flag (the CLI
|
|
79
|
+
validates this).
|
|
80
|
+
"""
|
|
81
|
+
base = f"{self.strategy_id}@{self.account_id}:{self.symbol}:{self.timeframe}"
|
|
82
|
+
return f"{base}#{self.label}" if self.label else base
|
|
83
|
+
|
|
84
|
+
def make_run_tag(self, script_source: str) -> str:
|
|
85
|
+
"""4-char base36 session tag for client_order_id.
|
|
86
|
+
|
|
87
|
+
The idempotency formula (``{run}-{pid}-{bar}-{k}{r}``) reserves 4
|
|
88
|
+
characters for this field, giving 20 bits of capacity
|
|
89
|
+
(~1M slots). Deterministic: the same input always produces the
|
|
90
|
+
same tag.
|
|
91
|
+
|
|
92
|
+
Why more inputs than the old ``make_run_tag(script_source)``:
|
|
93
|
+
the same script running on two timeframes simultaneously
|
|
94
|
+
produced identical tags, causing idempotency collisions
|
|
95
|
+
(identical ``client_order_id``s). Including ``strategy_id`` /
|
|
96
|
+
``symbol`` / ``timeframe`` / ``account`` / ``label`` extends the
|
|
97
|
+
collision space across the realistic run dimensions.
|
|
98
|
+
``strategy_id`` is also needed on its own: two distinct scripts
|
|
99
|
+
with identical sources (copy-paste) running on the same
|
|
100
|
+
(symbol, tf, account) would otherwise share a tag and emit
|
|
101
|
+
duplicate ``client_order_id``s at the broker.
|
|
102
|
+
|
|
103
|
+
The JSON-serialised input gives a deterministic stringification
|
|
104
|
+
free of pipe/quote/unicode edge cases.
|
|
105
|
+
|
|
106
|
+
:param script_source: Source code of the Pine script as the
|
|
107
|
+
runner read it.
|
|
108
|
+
:return: Exactly 4 lower-case base36 characters.
|
|
109
|
+
"""
|
|
110
|
+
payload = json.dumps(
|
|
111
|
+
[
|
|
112
|
+
self.strategy_id,
|
|
113
|
+
script_source,
|
|
114
|
+
self.symbol,
|
|
115
|
+
self.timeframe,
|
|
116
|
+
self.account_id,
|
|
117
|
+
self.label or "",
|
|
118
|
+
],
|
|
119
|
+
ensure_ascii=True,
|
|
120
|
+
sort_keys=False,
|
|
121
|
+
)
|
|
122
|
+
digest = hashlib.sha256(payload.encode('ascii')).digest()
|
|
123
|
+
# 20 bit → 4 char base36. A 3-byte slice covers it with margin;
|
|
124
|
+
# we AND-mask afterwards.
|
|
125
|
+
value = int.from_bytes(digest[:3], 'big') & _RUN_TAG_MASK
|
|
126
|
+
return _to_base36(value, width=RUN_TAG_WIDTH)
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Engine-side state machine for the STOP leg of a both-set Pine entry.
|
|
3
|
+
|
|
4
|
+
Pine has no "stop-limit" entry. ``strategy.entry(limit=L, stop=S)`` is two OCO
|
|
5
|
+
legs: a LIMIT below the open (a guaranteed-price pullback) and a STOP above
|
|
6
|
+
(market-on-rise) for a long — mirrored for a short. The first leg to fill
|
|
7
|
+
cancels the other.
|
|
8
|
+
|
|
9
|
+
The broker layer realises this asymmetrically so a double-fill race is
|
|
10
|
+
impossible by design:
|
|
11
|
+
|
|
12
|
+
- The LIMIT leg is a **native** working order (``execute_entry`` with
|
|
13
|
+
``order_type=LIMIT``). It rests on the exchange.
|
|
14
|
+
- The STOP leg is a **software** price-watch owned by this engine. When the
|
|
15
|
+
price crosses the stop level the engine cancels the native LIMIT and — only
|
|
16
|
+
once that cancel is *confirmed* — fires a MARKET order.
|
|
17
|
+
|
|
18
|
+
Because only one leg is ever native, there are never two simultaneous native
|
|
19
|
+
triggers: no race, and no need for a halt (a halt is forbidden for a live bot).
|
|
20
|
+
|
|
21
|
+
This module is the dispatch-time + watch-time counterpart of
|
|
22
|
+
:mod:`software_partial_bracket_engine`. The state machine and the persisted
|
|
23
|
+
watch rows in :mod:`store_helpers` form one pair: the rows are the durable
|
|
24
|
+
representation (PERSIST-FIRST), the in-memory :class:`EntryStopWatch` ledger is
|
|
25
|
+
the working set the WATCH phase reads on every price tick. On a clean restart
|
|
26
|
+
the ledger is rebuilt from the rows by :meth:`SoftwareEntryStopEngine.restart_replay`.
|
|
27
|
+
|
|
28
|
+
Ownership split (mirrors the partial-bracket engine): this state machine owns
|
|
29
|
+
only the in-memory ledger, the persisted transitions, and the restart replay.
|
|
30
|
+
Every broker-side action — the leg-scoped LIMIT cancel, the cancel-disposition
|
|
31
|
+
gate, and the MARKET dispatch — is performed by
|
|
32
|
+
:meth:`~pynecore.core.broker.sync_engine.OrderSyncEngine._drive_entry_stop_triggers`,
|
|
33
|
+
which calls the transition methods here at each step. That keeps the state
|
|
34
|
+
machine deterministic and unit-testable in isolation.
|
|
35
|
+
"""
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import TYPE_CHECKING, Iterable
|
|
38
|
+
|
|
39
|
+
from pynecore.core.broker.store_helpers import (
|
|
40
|
+
ENTRY_STOP_STATE_ABORTABLE,
|
|
41
|
+
ENTRY_STOP_STATE_ABORTED,
|
|
42
|
+
ENTRY_STOP_STATE_ARMED,
|
|
43
|
+
ENTRY_STOP_STATE_CANCEL_PENDING,
|
|
44
|
+
ENTRY_STOP_STATE_LIMIT_WON,
|
|
45
|
+
ENTRY_STOP_STATE_LIVE,
|
|
46
|
+
ENTRY_STOP_STATE_MARKET_PENDING,
|
|
47
|
+
ENTRY_STOP_STATE_STOP_WON,
|
|
48
|
+
EXTRAS_KEY_ENTRY_STOP_LEVEL,
|
|
49
|
+
EXTRAS_KEY_ENTRY_STOP_LIMIT_COID,
|
|
50
|
+
EXTRAS_KEY_ENTRY_STOP_MARKET_COID,
|
|
51
|
+
EXTRAS_KEY_ENTRY_STOP_STATE,
|
|
52
|
+
iter_active_entry_stop_watches,
|
|
53
|
+
update_entry_stop_watch_state,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if TYPE_CHECKING:
|
|
57
|
+
from pynecore.core.broker.storage import OrderRow, RunContext
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
'EntryStopWatch',
|
|
62
|
+
'SoftwareEntryStopEngine',
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class EntryStopWatch:
|
|
68
|
+
"""In-memory mirror of one entry-stop watch row.
|
|
69
|
+
|
|
70
|
+
Mirrors the canonical extras keys in :mod:`store_helpers`; the persistent
|
|
71
|
+
representation is authoritative (the engine reloads this struct from there
|
|
72
|
+
on restart). Keyed by ``pine_id`` — a both-set entry is exactly one Pine
|
|
73
|
+
entry id, so there is at most one live watch per id.
|
|
74
|
+
|
|
75
|
+
``side`` is the ENTRY side (``'buy'`` for a long, ``'sell'`` for a short):
|
|
76
|
+
the direction the stop-fired MARKET opens in, identical to the native
|
|
77
|
+
LIMIT leg's direction.
|
|
78
|
+
"""
|
|
79
|
+
coid: str
|
|
80
|
+
symbol: str
|
|
81
|
+
pine_id: str
|
|
82
|
+
side: str
|
|
83
|
+
qty: float
|
|
84
|
+
stop_level: float
|
|
85
|
+
limit_coid: str
|
|
86
|
+
state: str
|
|
87
|
+
market_coid: str | None = None
|
|
88
|
+
extras: dict = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def key(self) -> str:
|
|
92
|
+
return self.pine_id
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SoftwareEntryStopEngine:
|
|
96
|
+
"""In-memory state machine for both-set entry STOP legs.
|
|
97
|
+
|
|
98
|
+
One instance per :class:`~pynecore.core.broker.sync_engine.OrderSyncEngine`.
|
|
99
|
+
The engine does not talk to the broker directly; the sync engine performs
|
|
100
|
+
every broker-side action (leg-scoped LIMIT cancel, cancel-disposition gate,
|
|
101
|
+
MARKET dispatch) on the state machine's behalf, calling the transition
|
|
102
|
+
methods here at each step.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, store_ctx: 'RunContext | None') -> None:
|
|
106
|
+
self._store_ctx = store_ctx
|
|
107
|
+
self._watches: dict[str, EntryStopWatch] = {}
|
|
108
|
+
|
|
109
|
+
# === Registration =====================================================
|
|
110
|
+
|
|
111
|
+
def register_watch(self, watch: EntryStopWatch) -> None:
|
|
112
|
+
"""Add one freshly-persisted watch to the in-memory ledger.
|
|
113
|
+
|
|
114
|
+
Called by the sync engine after a successful
|
|
115
|
+
:func:`~pynecore.core.broker.store_helpers.create_entry_stop_watch_row`.
|
|
116
|
+
"""
|
|
117
|
+
if watch.state not in ENTRY_STOP_STATE_LIVE:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"register_watch: refuses to track watch in non-live state "
|
|
120
|
+
f"{watch.state!r} (pine_id={watch.pine_id!r})"
|
|
121
|
+
)
|
|
122
|
+
if watch.key in self._watches:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"register_watch: watch already tracked (pine_id="
|
|
125
|
+
f"{watch.pine_id!r}, existing coid={self._watches[watch.key].coid!r}, "
|
|
126
|
+
f"new coid={watch.coid!r})"
|
|
127
|
+
)
|
|
128
|
+
self._watches[watch.key] = watch
|
|
129
|
+
|
|
130
|
+
# === Queries ==========================================================
|
|
131
|
+
|
|
132
|
+
def get_watch(self, pine_id: str) -> EntryStopWatch | None:
|
|
133
|
+
return self._watches.get(pine_id)
|
|
134
|
+
|
|
135
|
+
def has_watch(self, pine_id: str) -> bool:
|
|
136
|
+
return pine_id in self._watches
|
|
137
|
+
|
|
138
|
+
def iter_watches(self) -> Iterable[EntryStopWatch]:
|
|
139
|
+
return list(self._watches.values())
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def stop_crossed(watch: EntryStopWatch, last_price: float) -> bool:
|
|
143
|
+
"""Whether the current price has crossed the watch's stop level.
|
|
144
|
+
|
|
145
|
+
A long both-set entry (``side='buy'``) places its STOP *above* the
|
|
146
|
+
open and fires when price rises to it; a short (``side='sell'``)
|
|
147
|
+
places its STOP *below* and fires when price falls to it.
|
|
148
|
+
"""
|
|
149
|
+
if watch.side == 'buy':
|
|
150
|
+
return last_price >= watch.stop_level
|
|
151
|
+
return last_price <= watch.stop_level
|
|
152
|
+
|
|
153
|
+
# === Transitions ======================================================
|
|
154
|
+
|
|
155
|
+
def begin_cancel(self, pine_id: str) -> EntryStopWatch | None:
|
|
156
|
+
"""``armed`` → ``cancel_pending``: the stop crossed, the engine is
|
|
157
|
+
about to cancel the native LIMIT leg.
|
|
158
|
+
|
|
159
|
+
Latched: once here, the decision to take the stop side is committed.
|
|
160
|
+
On restart the watch resumes in ``cancel_pending`` and the sync engine
|
|
161
|
+
re-drives the (idempotent) cancel gate regardless of the live price.
|
|
162
|
+
Idempotent no-op when the watch is not ``armed``.
|
|
163
|
+
"""
|
|
164
|
+
watch = self._watches.get(pine_id)
|
|
165
|
+
if watch is None or watch.state != ENTRY_STOP_STATE_ARMED:
|
|
166
|
+
return None
|
|
167
|
+
self._transition(watch, ENTRY_STOP_STATE_CANCEL_PENDING, close_row=False)
|
|
168
|
+
return watch
|
|
169
|
+
|
|
170
|
+
def confirm_limit_cancelled_fire_market(
|
|
171
|
+
self, pine_id: str, *, market_coid: str,
|
|
172
|
+
) -> EntryStopWatch | None:
|
|
173
|
+
"""``cancel_pending`` → ``stop_market_pending``: the LIMIT cancel is
|
|
174
|
+
CONFIRMED, so the MARKET may go.
|
|
175
|
+
|
|
176
|
+
Persists the deterministic MARKET client-order-id BEFORE the sync
|
|
177
|
+
engine POSTs it, so a crash-restart can verify-before-resend and never
|
|
178
|
+
double-open. Idempotent no-op when the watch is not ``cancel_pending``.
|
|
179
|
+
"""
|
|
180
|
+
watch = self._watches.get(pine_id)
|
|
181
|
+
if watch is None or watch.state != ENTRY_STOP_STATE_CANCEL_PENDING:
|
|
182
|
+
return None
|
|
183
|
+
watch.market_coid = market_coid
|
|
184
|
+
self._transition(
|
|
185
|
+
watch, ENTRY_STOP_STATE_MARKET_PENDING,
|
|
186
|
+
close_row=False, market_coid=market_coid,
|
|
187
|
+
)
|
|
188
|
+
return watch
|
|
189
|
+
|
|
190
|
+
def mark_stop_won(self, pine_id: str) -> EntryStopWatch | None:
|
|
191
|
+
"""``stop_market_pending`` → ``stop_won`` (terminal): the stop-fired
|
|
192
|
+
MARKET dispatch landed. Idempotent no-op otherwise.
|
|
193
|
+
"""
|
|
194
|
+
watch = self._watches.get(pine_id)
|
|
195
|
+
if watch is None or watch.state != ENTRY_STOP_STATE_MARKET_PENDING:
|
|
196
|
+
return None
|
|
197
|
+
self._transition(watch, ENTRY_STOP_STATE_STOP_WON, close_row=True)
|
|
198
|
+
return watch
|
|
199
|
+
|
|
200
|
+
def mark_limit_won(self, pine_id: str, *, reason: str) -> EntryStopWatch | None:
|
|
201
|
+
"""Any live state → ``limit_won`` (terminal): the native LIMIT leg
|
|
202
|
+
filled (or the cancel attempt reported ALREADY_FILLED), so the watch's
|
|
203
|
+
job is done and no MARKET must ever fire. Idempotent no-op when no live
|
|
204
|
+
watch matches.
|
|
205
|
+
"""
|
|
206
|
+
watch = self._watches.get(pine_id)
|
|
207
|
+
if watch is None:
|
|
208
|
+
return None
|
|
209
|
+
self._transition(
|
|
210
|
+
watch, ENTRY_STOP_STATE_LIMIT_WON,
|
|
211
|
+
close_row=True, extras_patch={'limit_won_reason': reason},
|
|
212
|
+
)
|
|
213
|
+
return watch
|
|
214
|
+
|
|
215
|
+
def mark_aborted(self, pine_id: str, *, reason: str) -> EntryStopWatch | None:
|
|
216
|
+
"""Abortable state (``armed`` / ``cancel_pending``) → ``aborted``
|
|
217
|
+
(terminal): the strategy cancelled the parent entry or its native LIMIT
|
|
218
|
+
leg went away before the stop side committed, so the watch stops
|
|
219
|
+
watching and never fires.
|
|
220
|
+
|
|
221
|
+
Idempotent no-op once the watch has committed to the stop side
|
|
222
|
+
(``stop_market_pending`` — the deterministic KIND_ENTRY_STOP market id
|
|
223
|
+
is already persisted and the MARKET is in flight) or terminalised, so a
|
|
224
|
+
delayed broker cancelled/rejected ack for the OCO's own now-cancelled
|
|
225
|
+
LIMIT cannot retire a watch that has already fired the market. Also a
|
|
226
|
+
no-op when no watch matches.
|
|
227
|
+
"""
|
|
228
|
+
watch = self._watches.get(pine_id)
|
|
229
|
+
if watch is None or watch.state not in ENTRY_STOP_STATE_ABORTABLE:
|
|
230
|
+
return None
|
|
231
|
+
self._transition(
|
|
232
|
+
watch, ENTRY_STOP_STATE_ABORTED,
|
|
233
|
+
close_row=True, extras_patch={'abort_reason': reason},
|
|
234
|
+
)
|
|
235
|
+
return watch
|
|
236
|
+
|
|
237
|
+
def amend_watch(
|
|
238
|
+
self, pine_id: str, *, stop_level: float, qty: float, side: str,
|
|
239
|
+
) -> EntryStopWatch | None:
|
|
240
|
+
"""Re-sync an ``armed`` watch to an amended both-set entry.
|
|
241
|
+
|
|
242
|
+
When the strategy re-emits the same ``pine_id`` both-set entry with a
|
|
243
|
+
changed ``stop`` / ``qty`` / ``side``, the sync engine amends the native
|
|
244
|
+
LIMIT leg (``modify_entry``) and calls this to keep the software STOP
|
|
245
|
+
leg in step. ``modify_entry`` preserves the native LIMIT's
|
|
246
|
+
:data:`~pynecore.core.broker.idempotency.KIND_ENTRY` client-order-id
|
|
247
|
+
(the dispatch envelope anchor is pinned per ``intent_key`` across amend
|
|
248
|
+
cycles), so the watch's leg-scoped cancel target
|
|
249
|
+
(:attr:`EntryStopWatch.limit_coid`) stays valid — only the fire level,
|
|
250
|
+
size, and side change.
|
|
251
|
+
|
|
252
|
+
PERSIST-FIRST: the watch row is updated before the in-memory mirror.
|
|
253
|
+
Idempotent guards:
|
|
254
|
+
|
|
255
|
+
- no watch for ``pine_id`` → ``None`` (the entry has no STOP leg, e.g. a
|
|
256
|
+
plain limit-only entry); the caller treats this as a no-op.
|
|
257
|
+
- the watch has left ``armed`` (``cancel_pending`` /
|
|
258
|
+
``stop_market_pending`` — the OCO is already resolving toward the stop
|
|
259
|
+
side) → returned unchanged WITHOUT amending: rewinding the fire level /
|
|
260
|
+
size mid-cancel would race the in-flight LIMIT cancel and the
|
|
261
|
+
deterministic MARKET identity. The next terminalisation settles it.
|
|
262
|
+
"""
|
|
263
|
+
watch = self._watches.get(pine_id)
|
|
264
|
+
if watch is None:
|
|
265
|
+
return None
|
|
266
|
+
if watch.state != ENTRY_STOP_STATE_ARMED:
|
|
267
|
+
return watch
|
|
268
|
+
if self._store_ctx is not None:
|
|
269
|
+
update_entry_stop_watch_state(
|
|
270
|
+
self._store_ctx,
|
|
271
|
+
coid=watch.coid,
|
|
272
|
+
new_state=watch.state,
|
|
273
|
+
stop_level=stop_level,
|
|
274
|
+
qty=qty,
|
|
275
|
+
side=side,
|
|
276
|
+
)
|
|
277
|
+
watch.stop_level = stop_level
|
|
278
|
+
watch.qty = qty
|
|
279
|
+
watch.side = side
|
|
280
|
+
return watch
|
|
281
|
+
|
|
282
|
+
# === Restart replay ===================================================
|
|
283
|
+
|
|
284
|
+
def restart_replay(self) -> None:
|
|
285
|
+
"""Rebuild the in-memory ledger from persisted watch rows.
|
|
286
|
+
|
|
287
|
+
Called once by the sync engine during startup, after the journal's
|
|
288
|
+
regular replay but before the first :meth:`sync`. Terminal rows are
|
|
289
|
+
filtered out by :func:`iter_active_entry_stop_watches`. The latched
|
|
290
|
+
intermediate states (``cancel_pending`` / ``stop_market_pending``) are
|
|
291
|
+
reloaded as-is: the next :meth:`_drive_entry_stop_triggers` re-drives
|
|
292
|
+
them deterministically (re-issue the idempotent cancel / re-dispatch
|
|
293
|
+
the idempotent MARKET), so no demotion is needed.
|
|
294
|
+
"""
|
|
295
|
+
if self._store_ctx is None:
|
|
296
|
+
return
|
|
297
|
+
self._watches.clear()
|
|
298
|
+
for row in iter_active_entry_stop_watches(self._store_ctx):
|
|
299
|
+
watch = _watch_from_row(row)
|
|
300
|
+
if watch is None:
|
|
301
|
+
continue
|
|
302
|
+
self._watches[watch.key] = watch
|
|
303
|
+
|
|
304
|
+
# === State machine plumbing ==========================================
|
|
305
|
+
|
|
306
|
+
def _transition(
|
|
307
|
+
self,
|
|
308
|
+
watch: EntryStopWatch,
|
|
309
|
+
new_state: str,
|
|
310
|
+
*,
|
|
311
|
+
close_row: bool,
|
|
312
|
+
market_coid: str | None = None,
|
|
313
|
+
extras_patch: dict | None = None,
|
|
314
|
+
) -> None:
|
|
315
|
+
watch.state = new_state
|
|
316
|
+
if extras_patch:
|
|
317
|
+
watch.extras.update(extras_patch)
|
|
318
|
+
if self._store_ctx is not None:
|
|
319
|
+
update_entry_stop_watch_state(
|
|
320
|
+
self._store_ctx,
|
|
321
|
+
coid=watch.coid,
|
|
322
|
+
new_state=new_state,
|
|
323
|
+
market_coid=market_coid,
|
|
324
|
+
extras_patch=extras_patch,
|
|
325
|
+
close_row=close_row,
|
|
326
|
+
)
|
|
327
|
+
if close_row:
|
|
328
|
+
self._watches.pop(watch.key, None)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _watch_from_row(row: 'OrderRow') -> EntryStopWatch | None:
|
|
332
|
+
extras = row.extras or {}
|
|
333
|
+
state = extras.get(EXTRAS_KEY_ENTRY_STOP_STATE, '')
|
|
334
|
+
if state not in ENTRY_STOP_STATE_LIVE:
|
|
335
|
+
return None
|
|
336
|
+
stop_level: float | None = extras.get(EXTRAS_KEY_ENTRY_STOP_LEVEL)
|
|
337
|
+
limit_coid: str | None = extras.get(EXTRAS_KEY_ENTRY_STOP_LIMIT_COID)
|
|
338
|
+
if stop_level is None or limit_coid is None:
|
|
339
|
+
return None
|
|
340
|
+
return EntryStopWatch(
|
|
341
|
+
coid=row.client_order_id,
|
|
342
|
+
symbol=row.symbol,
|
|
343
|
+
pine_id=row.pine_entry_id or '',
|
|
344
|
+
side=row.side,
|
|
345
|
+
qty=row.qty,
|
|
346
|
+
stop_level=float(stop_level),
|
|
347
|
+
limit_coid=limit_coid,
|
|
348
|
+
state=state,
|
|
349
|
+
market_coid=extras.get(EXTRAS_KEY_ENTRY_STOP_MARKET_COID),
|
|
350
|
+
extras=dict(extras),
|
|
351
|
+
)
|