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.
Files changed (261) hide show
  1. opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
  2. opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
  3. opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
  4. opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
  5. opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
  6. opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
  7. opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
  8. pynecore/__init__.py +6 -0
  9. pynecore/cli/__init__.py +2 -0
  10. pynecore/cli/app.py +238 -0
  11. pynecore/cli/commands/__init__.py +343 -0
  12. pynecore/cli/commands/benchmark.py +186 -0
  13. pynecore/cli/commands/compile.py +198 -0
  14. pynecore/cli/commands/data.py +857 -0
  15. pynecore/cli/commands/debug.py +63 -0
  16. pynecore/cli/commands/optimize.py +956 -0
  17. pynecore/cli/commands/plugin.py +242 -0
  18. pynecore/cli/commands/run.py +2006 -0
  19. pynecore/cli/pluggable.py +132 -0
  20. pynecore/cli/utils/__init__.py +0 -0
  21. pynecore/cli/utils/api_error_handler.py +168 -0
  22. pynecore/cli/utils/broker_picker.py +330 -0
  23. pynecore/cli/utils/error_hook.py +28 -0
  24. pynecore/cli/utils/keyreader.py +178 -0
  25. pynecore/cli/utils/provider_picker.py +19 -0
  26. pynecore/cli/utils/symbol_browser.py +1149 -0
  27. pynecore/core/__init__.py +0 -0
  28. pynecore/core/aggregator.py +257 -0
  29. pynecore/core/bar_magnifier.py +168 -0
  30. pynecore/core/broker/__init__.py +64 -0
  31. pynecore/core/broker/defaults.py +113 -0
  32. pynecore/core/broker/disappearance.py +927 -0
  33. pynecore/core/broker/emulator.py +345 -0
  34. pynecore/core/broker/exceptions.py +346 -0
  35. pynecore/core/broker/idempotency.py +401 -0
  36. pynecore/core/broker/intent_builder.py +334 -0
  37. pynecore/core/broker/journal.py +1785 -0
  38. pynecore/core/broker/models.py +1600 -0
  39. pynecore/core/broker/native_failsafe_manager.py +1436 -0
  40. pynecore/core/broker/one_way_emulator.py +1128 -0
  41. pynecore/core/broker/position.py +787 -0
  42. pynecore/core/broker/run_identity.py +126 -0
  43. pynecore/core/broker/software_entry_stop_engine.py +351 -0
  44. pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
  45. pynecore/core/broker/spot_inventory.py +1327 -0
  46. pynecore/core/broker/storage.py +2655 -0
  47. pynecore/core/broker/store_helpers.py +2161 -0
  48. pynecore/core/broker/sync_engine.py +16070 -0
  49. pynecore/core/broker/validation.py +382 -0
  50. pynecore/core/class_property.py +7 -0
  51. pynecore/core/config.py +392 -0
  52. pynecore/core/csv_file.py +547 -0
  53. pynecore/core/currency.py +262 -0
  54. pynecore/core/data_converter.py +1002 -0
  55. pynecore/core/datetime.py +296 -0
  56. pynecore/core/download_info.py +71 -0
  57. pynecore/core/download_runner.py +274 -0
  58. pynecore/core/htf_aggregator.py +181 -0
  59. pynecore/core/import_hook.py +358 -0
  60. pynecore/core/instance_state.py +494 -0
  61. pynecore/core/live_ltf_collector.py +442 -0
  62. pynecore/core/live_ltf_window.py +189 -0
  63. pynecore/core/live_runner.py +1347 -0
  64. pynecore/core/module_property.py +26 -0
  65. pynecore/core/ohlcv_file.py +1888 -0
  66. pynecore/core/overload.py +371 -0
  67. pynecore/core/pine_cast.py +113 -0
  68. pynecore/core/pine_export.py +95 -0
  69. pynecore/core/pine_method.py +244 -0
  70. pynecore/core/pine_range.py +86 -0
  71. pynecore/core/pine_udt.py +69 -0
  72. pynecore/core/plugin/__init__.py +394 -0
  73. pynecore/core/plugin/broker.py +781 -0
  74. pynecore/core/plugin/cli.py +96 -0
  75. pynecore/core/plugin/live_provider.py +208 -0
  76. pynecore/core/plugin/provider.py +331 -0
  77. pynecore/core/provider_string.py +148 -0
  78. pynecore/core/random.py +40 -0
  79. pynecore/core/resampler.py +686 -0
  80. pynecore/core/safe_convert.py +64 -0
  81. pynecore/core/script.py +1011 -0
  82. pynecore/core/script_runner.py +3202 -0
  83. pynecore/core/security.py +1749 -0
  84. pynecore/core/security_process.py +1253 -0
  85. pynecore/core/security_shm.py +456 -0
  86. pynecore/core/series.py +417 -0
  87. pynecore/core/strategy_stats.py +669 -0
  88. pynecore/core/symbol_map.py +134 -0
  89. pynecore/core/syminfo.py +505 -0
  90. pynecore/core/viz.py +591 -0
  91. pynecore/lib/__init__.py +1771 -0
  92. pynecore/lib/_fixnan.py +32 -0
  93. pynecore/lib/_math_stateful.py +202 -0
  94. pynecore/lib/_timeframe_change.py +101 -0
  95. pynecore/lib/adjustment.py +6 -0
  96. pynecore/lib/alert.py +39 -0
  97. pynecore/lib/alert.pyi +14 -0
  98. pynecore/lib/array.py +1051 -0
  99. pynecore/lib/barmerge.py +60 -0
  100. pynecore/lib/barstate.py +30 -0
  101. pynecore/lib/box.py +415 -0
  102. pynecore/lib/chart.py +128 -0
  103. pynecore/lib/color.py +152 -0
  104. pynecore/lib/color.pyi +50 -0
  105. pynecore/lib/currency.py +62 -0
  106. pynecore/lib/dayofweek.py +36 -0
  107. pynecore/lib/dayofweek.pyi +18 -0
  108. pynecore/lib/display.py +8 -0
  109. pynecore/lib/dividends.py +9 -0
  110. pynecore/lib/earnings.py +11 -0
  111. pynecore/lib/extend.py +6 -0
  112. pynecore/lib/font.py +5 -0
  113. pynecore/lib/footprint.py +79 -0
  114. pynecore/lib/format.py +11 -0
  115. pynecore/lib/hline.py +67 -0
  116. pynecore/lib/hline.pyi +24 -0
  117. pynecore/lib/label.py +409 -0
  118. pynecore/lib/line.py +433 -0
  119. pynecore/lib/linefill.py +93 -0
  120. pynecore/lib/location.py +11 -0
  121. pynecore/lib/log.py +362 -0
  122. pynecore/lib/map.py +150 -0
  123. pynecore/lib/math.py +385 -0
  124. pynecore/lib/matrix.py +708 -0
  125. pynecore/lib/order.py +8 -0
  126. pynecore/lib/pivotpointtype.py +8 -0
  127. pynecore/lib/plot.py +95 -0
  128. pynecore/lib/plot.pyi +33 -0
  129. pynecore/lib/polyline.py +91 -0
  130. pynecore/lib/position.py +15 -0
  131. pynecore/lib/request.py +281 -0
  132. pynecore/lib/runtime.py +5 -0
  133. pynecore/lib/scale.py +9 -0
  134. pynecore/lib/session.py +267 -0
  135. pynecore/lib/session.pyi +12 -0
  136. pynecore/lib/shape.py +18 -0
  137. pynecore/lib/size.py +12 -0
  138. pynecore/lib/splits.py +4 -0
  139. pynecore/lib/strategy/__init__.py +4778 -0
  140. pynecore/lib/strategy/closedtrades.py +347 -0
  141. pynecore/lib/strategy/closedtrades.pyi +53 -0
  142. pynecore/lib/strategy/commission.py +9 -0
  143. pynecore/lib/strategy/direction.py +9 -0
  144. pynecore/lib/strategy/oca.py +13 -0
  145. pynecore/lib/strategy/opentrades.py +281 -0
  146. pynecore/lib/strategy/opentrades.pyi +49 -0
  147. pynecore/lib/strategy/risk.py +109 -0
  148. pynecore/lib/string.py +649 -0
  149. pynecore/lib/syminfo.py +84 -0
  150. pynecore/lib/ta.py +2230 -0
  151. pynecore/lib/table.py +290 -0
  152. pynecore/lib/text.py +17 -0
  153. pynecore/lib/ticker.py +207 -0
  154. pynecore/lib/timeframe.py +293 -0
  155. pynecore/lib/volume_row.py +67 -0
  156. pynecore/lib/xloc.py +4 -0
  157. pynecore/lib/yloc.py +5 -0
  158. pynecore/providers/__init__.py +0 -0
  159. pynecore/providers/ccxt.py +664 -0
  160. pynecore/providers/replay.py +187 -0
  161. pynecore/pynesys/__init__.py +0 -0
  162. pynecore/pynesys/api.py +498 -0
  163. pynecore/pynesys/compiler.py +112 -0
  164. pynecore/standalone.py +99 -0
  165. pynecore/testing/__init__.py +1 -0
  166. pynecore/testing/broker_lab/__init__.py +41 -0
  167. pynecore/testing/broker_lab/__main__.py +5 -0
  168. pynecore/testing/broker_lab/cli.py +87 -0
  169. pynecore/testing/broker_lab/generate.py +47 -0
  170. pynecore/testing/broker_lab/model.py +84 -0
  171. pynecore/testing/broker_lab/reference.py +645 -0
  172. pynecore/testing/broker_lab/runner.py +372 -0
  173. pynecore/testing/broker_lab/scheduler.py +50 -0
  174. pynecore/testing/broker_lab/subprocess.py +73 -0
  175. pynecore/transformers/__init__.py +0 -0
  176. pynecore/transformers/builtin_shadow.py +136 -0
  177. pynecore/transformers/closure_arguments_transformer.py +428 -0
  178. pynecore/transformers/display_rewrite.py +140 -0
  179. pynecore/transformers/dynamic_default.py +147 -0
  180. pynecore/transformers/function_isolation.py +757 -0
  181. pynecore/transformers/import_lifter.py +61 -0
  182. pynecore/transformers/import_normalizer.py +328 -0
  183. pynecore/transformers/inline_series_hoist.py +178 -0
  184. pynecore/transformers/input_transformer.py +175 -0
  185. pynecore/transformers/lib_series.py +201 -0
  186. pynecore/transformers/locations.py +70 -0
  187. pynecore/transformers/module_properties.json +3387 -0
  188. pynecore/transformers/module_property.py +221 -0
  189. pynecore/transformers/ne_guard.py +70 -0
  190. pynecore/transformers/persistent.py +320 -0
  191. pynecore/transformers/persistent_series.py +76 -0
  192. pynecore/transformers/safe_convert_transformer.py +97 -0
  193. pynecore/transformers/safe_division_transformer.py +95 -0
  194. pynecore/transformers/script_requirements.py +308 -0
  195. pynecore/transformers/security.py +752 -0
  196. pynecore/transformers/security_instantiation.py +274 -0
  197. pynecore/transformers/series.py +275 -0
  198. pynecore/transformers/slot_layout.py +381 -0
  199. pynecore/transformers/type_checking_stripper.py +25 -0
  200. pynecore/transformers/unused_series_detector.py +267 -0
  201. pynecore/types/__init__.py +21 -0
  202. pynecore/types/alert.py +5 -0
  203. pynecore/types/barmerge.py +5 -0
  204. pynecore/types/base.py +39 -0
  205. pynecore/types/box.py +37 -0
  206. pynecore/types/chart.py +17 -0
  207. pynecore/types/color.py +107 -0
  208. pynecore/types/currency.py +5 -0
  209. pynecore/types/datetime.py +6 -0
  210. pynecore/types/display.py +5 -0
  211. pynecore/types/dividends.py +5 -0
  212. pynecore/types/earnings.py +5 -0
  213. pynecore/types/extend.py +5 -0
  214. pynecore/types/font.py +5 -0
  215. pynecore/types/footprint.py +41 -0
  216. pynecore/types/format.py +5 -0
  217. pynecore/types/hline.py +24 -0
  218. pynecore/types/ib_persistent.py +8 -0
  219. pynecore/types/ib_persistent.pyi +10 -0
  220. pynecore/types/label.py +35 -0
  221. pynecore/types/line.py +32 -0
  222. pynecore/types/linefill.py +13 -0
  223. pynecore/types/location.py +5 -0
  224. pynecore/types/matrix.py +999 -0
  225. pynecore/types/na.py +237 -0
  226. pynecore/types/na.pyi +83 -0
  227. pynecore/types/ohlcv.py +12 -0
  228. pynecore/types/order.py +5 -0
  229. pynecore/types/persistent.py +8 -0
  230. pynecore/types/persistent.pyi +13 -0
  231. pynecore/types/pine_types.py +11 -0
  232. pynecore/types/pine_types.pyi +15 -0
  233. pynecore/types/pivotpointtype.py +5 -0
  234. pynecore/types/plot.py +12 -0
  235. pynecore/types/plot_meta.py +60 -0
  236. pynecore/types/polyline.py +40 -0
  237. pynecore/types/position.py +5 -0
  238. pynecore/types/scale.py +5 -0
  239. pynecore/types/script_type.py +15 -0
  240. pynecore/types/series.py +23 -0
  241. pynecore/types/series.pyi +19 -0
  242. pynecore/types/session.py +35 -0
  243. pynecore/types/shape.py +5 -0
  244. pynecore/types/size.py +5 -0
  245. pynecore/types/source.py +33 -0
  246. pynecore/types/splits.py +5 -0
  247. pynecore/types/strategy.py +45 -0
  248. pynecore/types/table.py +87 -0
  249. pynecore/types/text.py +13 -0
  250. pynecore/types/type_checker.py +7 -0
  251. pynecore/types/type_checker.pyi +48 -0
  252. pynecore/types/volume_row.py +36 -0
  253. pynecore/types/weekdays.py +11 -0
  254. pynecore/types/xloc.py +5 -0
  255. pynecore/types/yloc.py +5 -0
  256. pynecore/utils/__init__.py +0 -0
  257. pynecore/utils/file_utils.py +50 -0
  258. pynecore/utils/rich/__init__.py +0 -0
  259. pynecore/utils/rich/date_column.py +25 -0
  260. pynecore/utils/sequence_view.py +92 -0
  261. 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
+ )