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,3202 @@
1
+ from typing import Iterable, Iterator, Callable, TYPE_CHECKING, Any, cast
2
+ from types import ModuleType
3
+ import asyncio
4
+ import sys
5
+ import tomllib
6
+ from dataclasses import dataclass, field as dataclasses_field
7
+ from functools import partial
8
+ from math import log10, floor, frexp
9
+ from pathlib import Path
10
+ from datetime import datetime, UTC
11
+
12
+ from pynecore import lib
13
+ from pynecore.lib.log import broker_debug, broker_info, broker_warning, ohlcv_info, sim_info
14
+ from pynecore.core.broker.exceptions import ExchangeConnectionError
15
+ from pynecore.types.ohlcv import OHLCV
16
+ from pynecore.types.na import na_float
17
+ from pynecore.core.syminfo import SymInfo, mintick_decimals
18
+ from pynecore.core.csv_file import CSVWriter
19
+ from pynecore.core.strategy_stats import (
20
+ calculate_strategy_statistics, write_strategy_statistics_csv, StrategyStatistics)
21
+ from pynecore.core import viz
22
+ from pynecore.core.viz import VizWriter
23
+
24
+ from pynecore.types import script_type
25
+ from pynecore.core.plugin.live_provider import PluginSymbol
26
+
27
+ if TYPE_CHECKING:
28
+ from multiprocessing.process import BaseProcess
29
+ from zoneinfo import ZoneInfo
30
+ from pynecore.core.script import script
31
+ from pynecore.lib.strategy import Trade, SimPosition
32
+ from pynecore.core.broker.position import BrokerPosition
33
+ from pynecore.core.plugin.broker import BrokerPlugin
34
+ from pynecore.core.plugin.live_provider import LiveProviderPlugin
35
+ from pynecore.core.broker.sync_engine import OrderSyncEngine
36
+ from pynecore.core.broker.storage import RunContext
37
+ from pynecore.core.broker.models import ScriptRequirements
38
+ from pynecore.core.symbol_map import MappedSymbol
39
+
40
+ __all__ = [
41
+ 'import_script',
42
+ 'ScriptRunner',
43
+ 'LIVE_TRANSITION',
44
+ 'SecurityRequirement',
45
+ 'DataRequirements',
46
+ ]
47
+
48
+ LIVE_TRANSITION = OHLCV(timestamp=-1, open=-1, high=-1, low=-1, close=-1, volume=-1)
49
+ """Sentinel inserted between historical and live OHLCV data in the iterator."""
50
+
51
+
52
+ def _close_price_or_none() -> float | None:
53
+ """Best-effort current bar close, ``None`` before any bar is ingested.
54
+
55
+ The runner rebinds ``lib.close`` to a float on every bar; at startup
56
+ (and during a pre-bar refresh window) it still holds the
57
+ :class:`~pynecore.types.source.Source` sentinel placeholder. Returning
58
+ ``None`` in that case lets the broker engine's partial-bracket WATCH
59
+ phase short-circuit cleanly until a real price lands.
60
+ """
61
+ val = getattr(lib, 'close', None)
62
+ if isinstance(val, (int, float)):
63
+ return float(val)
64
+ return None
65
+
66
+
67
+ def import_script(script_path: Path) -> ModuleType:
68
+ """
69
+ Import the script
70
+ """
71
+ # ``pynecore`` can resolve as a namespace package when the CLI is launched
72
+ # from the monorepo root (the checkout's top-level ``pynecore/`` directory
73
+ # shadows the editable ``src/pynecore`` package). In that case
74
+ # ``pynecore.__init__`` never runs, so relying on it to install the Pyne
75
+ # import hook lets a valid foreign ``.pyc`` bypass every AST transform.
76
+ # Import the hook at the actual script-import boundary as the definitive
77
+ # installation point; the module import is idempotent in normal installs.
78
+ from . import import_hook as _import_hook # noqa: F401
79
+ from importlib import import_module
80
+ import re
81
+
82
+ # Check for @pyne magic doc comment before importing (prevents import errors)
83
+ # Without this user may get strange errors which are very hard to debug
84
+ try:
85
+ with open(script_path, 'r') as f:
86
+ # Read only the first few lines to check for docstring
87
+ content = f.read(1024) # Read first 1KB, should be enough for docstring check
88
+
89
+ # Check if file starts with a docstring containing @pyne
90
+ if not re.search(r'^(""".*?@pyne.*?"""|\'\'\'.*?@pyne.*?\'\'\')',
91
+ content, re.DOTALL | re.MULTILINE):
92
+ raise ImportError(
93
+ f"Script '{script_path}' must have a magic doc comment containing "
94
+ f"'@pyne' at the beginning of the file!"
95
+ )
96
+ except (OSError, IOError) as e:
97
+ raise ImportError(f"Could not read script file '{script_path}': {e}")
98
+
99
+ # Add script's directory to Python path temporarily
100
+ sys.path.insert(0, str(script_path.parent))
101
+ try:
102
+ # Import hook is registered at pynecore package import time (see pynecore/__init__.py),
103
+ # so any subsequent import goes through PyneLoader and AST transformers.
104
+ module = import_module(script_path.stem)
105
+ finally:
106
+ # Remove the directory from path
107
+ sys.path.pop(0)
108
+
109
+ if not hasattr(module, 'main'):
110
+ raise ImportError(f"Script '{script_path}' must have a 'main' function to run!")
111
+
112
+ return module
113
+
114
+
115
+ def _round_price(price: float, tick_decimals: int | None):
116
+ """
117
+ Clean float32 ``.ohlcv`` storage artifacts from an OHLC price, keeping the
118
+ finer of the mintick grid and a 6-significant-digit clean-up.
119
+
120
+ The float32 OHLCV format stores prices with sub-tick error (mintick=0.01
121
+ turns 93761.9 into 93761.8984; 109547.84 into 109547.836). Two clean-up
122
+ grids matter, and the right one depends on price magnitude:
123
+
124
+ - **6 significant digits** (``5 - floor(log10|price|)`` decimals) is the
125
+ historical heuristic. It is correct for small prices, where TradingView
126
+ itself carries sub-mintick precision (e.g. close=4.38075 on a coarser
127
+ tick), so it must NOT be snapped to the tick.
128
+ - **mintick decimals** is needed for large prices: at BTC ~94000, 6 sig
129
+ digits only reaches 1 decimal (93898.05 -> 93898.1) and discards the real
130
+ mintick-aligned precision, which flips threshold/hysteresis indicators.
131
+
132
+ Taking ``max`` of the two decimal counts keeps the finer grid in both
133
+ regimes — never coarser than the old 6-sig behaviour, only finer when the
134
+ mintick demands it. ``tick_decimals`` is ``None`` when the symbol has no
135
+ real mintick, falling back to the 6-sig clean-up alone.
136
+ """
137
+ if price == 0.0:
138
+ return 0.0
139
+ precision = 5 - floor(log10(abs(price))) # 6 significant digits
140
+ if tick_decimals is not None and tick_decimals > precision:
141
+ precision = tick_decimals
142
+ return round(price, precision)
143
+
144
+
145
+ def _round_volume(volume: float) -> float:
146
+ """
147
+ Clean float32 ``.ohlcv`` storage artifacts from a volume, mirroring
148
+ :func:`_round_price` for the volume field.
149
+
150
+ Data feeds serve volume as a short decimal (e.g. Binance BTCUSDT lot step
151
+ ``1e-5``: ``0.56881``), which has no exact binary float32 form — the raw
152
+ stored value reads back as ``0.568809986...``, and the ~1e-7 per-bar dust
153
+ accumulates in every volume sum a script computes. Rounding restores the
154
+ original decimal exactly wherever float32 can vouch for it: keep the
155
+ decimals whose grid is no finer than the float32 ulp at this magnitude
156
+ (from ``frexp``: ulp = 2^(exp-24)), but never fewer than 5 (the finest
157
+ common lot grid at magnitudes where restoration is still exact). Above
158
+ that magnitude the 5-decimal grid is finer than the float32 spacing, so
159
+ rounding adds nothing to the storage error; clean feed values (e.g.
160
+ integer share counts) pass through unchanged.
161
+ """
162
+ if volume == 0.0 or volume != volume: # zero or na
163
+ return volume
164
+ ulp_exp = frexp(volume)[1] - 24 # float32 ulp = 2**ulp_exp
165
+ precision = max(5, floor(-ulp_exp * 0.30102999566398120)) # log10(2)
166
+ return round(volume, precision)
167
+
168
+
169
+ # noinspection PyShadowingNames,PyUnusedLocal
170
+ def _set_lib_properties(ohlcv: OHLCV, bar_index: int, tz: 'ZoneInfo', lib: ModuleType,
171
+ round_decimals: int | None, last_bar_index: int | None = None,
172
+ last_bar_time: int | None = None):
173
+ """
174
+ Set lib properties from OHLCV
175
+ """
176
+ if TYPE_CHECKING: # This is needed for the type checker to work
177
+ from .. import lib
178
+
179
+ lib.bar_index = bar_index
180
+ lib.last_bar_index = bar_index if last_bar_index is None else last_bar_index
181
+
182
+ lib.open = o = _round_price(ohlcv.open, round_decimals)
183
+ lib.high = h = _round_price(ohlcv.high, round_decimals)
184
+ lib.low = lo = _round_price(ohlcv.low, round_decimals)
185
+ lib.close = c = _round_price(ohlcv.close, round_decimals)
186
+
187
+ lib.volume = _round_volume(ohlcv.volume)
188
+ lib.extra_fields = ohlcv.extra_fields if ohlcv.extra_fields else {}
189
+
190
+ # Pine's ``bid``/``ask`` only carry real values on the ``"1T"`` (tick) feed; on every
191
+ # other timeframe TradingView reports ``na``. PyneCore does not support tick data, so
192
+ # they are always ``na`` — matching TV behaviour on bar timeframes.
193
+ lib.bid = lib.ask = na_float
194
+
195
+ lib.hl2 = (h + lo) / 2.0
196
+ lib.hlc3 = (h + lo + c) / 3.0
197
+ lib.ohlc4 = (o + h + lo + c) / 4.0
198
+ lib.hlcc4 = (h + lo + 2 * c) / 4.0
199
+
200
+ # ``fromtimestamp(ts, tz)`` converts straight to the exchange timezone (same
201
+ # instant as a UTC roundtrip), and the epoch milliseconds come directly from
202
+ # the raw timestamp — no astimezone/timestamp C calls per bar.
203
+ lib._datetime = datetime.fromtimestamp(ohlcv.timestamp, tz)
204
+ lib._time = t = int(ohlcv.timestamp * 1000) # PineScript representation of time
205
+ # Historical runs anchor ``last_bar_time`` to the chart's final bar (Pine
206
+ # semantics — the whole history is known up front); live updates pass
207
+ # ``None`` so it tracks the current (realtime) bar, which IS the last bar.
208
+ lib.last_bar_time = t if last_bar_time is None else last_bar_time
209
+
210
+ # Multi-period scheduled-grid tracker (lib._dg_*): one compare per bar,
211
+ # the roll path runs at most once per trading day
212
+ if ohlcv.timestamp >= lib._dg_next_roll:
213
+ lib._dg_on_roll(ohlcv.timestamp)
214
+ # Remember this bar so the next roll can measure the day it closes (the
215
+ # holiday half-day fold needs the previous day's last bar end).
216
+ lib._dg_last_ts = ohlcv.timestamp
217
+
218
+
219
+ # noinspection PyUnusedLocal
220
+ def _set_lib_syminfo_properties(syminfo: SymInfo):
221
+ """
222
+ Set syminfo library properties from this object
223
+ """
224
+ for slot_name in syminfo.__slots__: # type: ignore
225
+ value = getattr(syminfo, slot_name)
226
+ if value is not None:
227
+ try:
228
+ setattr(lib.syminfo, slot_name, value)
229
+ except AttributeError:
230
+ pass
231
+
232
+ lib.syminfo.root = syminfo.ticker
233
+ lib.syminfo.tickerid = syminfo.prefix + ':' + syminfo.ticker
234
+ lib.syminfo.ticker = lib.syminfo.tickerid
235
+ lib.syminfo.main_tickerid = lib.syminfo.tickerid
236
+
237
+ lib.syminfo._opening_hours = syminfo.opening_hours
238
+ lib.syminfo._session_starts = syminfo.session_starts
239
+ lib.syminfo._session_ends = syminfo.session_ends
240
+
241
+ # Order sizes are truncated to the symbol's quantity grid, exactly like TV
242
+ # floors sizes to syminfo.mincontract. SymInfo guarantees a positive value
243
+ # (exchange value, volume-data analysis or heuristic fallback).
244
+ factor = round(1.0 / syminfo.mincontract) if syminfo.mincontract > 0 else 1
245
+ lib.syminfo._size_round_factor = max(1, factor)
246
+
247
+
248
+ # noinspection PyProtectedMember
249
+ def _reset_lib_vars():
250
+ """
251
+ Reset lib variables to be able to run other scripts
252
+ """
253
+ from ..types.source import Source
254
+
255
+ lib.open = Source("open")
256
+ lib.high = Source("high")
257
+ lib.low = Source("low")
258
+ lib.close = Source("close")
259
+ lib.volume = Source("volume")
260
+ lib.bid = Source("bid")
261
+ lib.ask = Source("ask")
262
+ lib.hl2 = Source("hl2")
263
+ lib.hlc3 = Source("hlc3")
264
+ lib.ohlc4 = Source("ohlc4")
265
+ lib.hlcc4 = Source("hlcc4")
266
+
267
+ lib._time = 0
268
+ lib._datetime = datetime.fromtimestamp(0, UTC)
269
+
270
+ lib.extra_fields = {}
271
+ lib._lib_semaphore = False
272
+ lib._is_live = False
273
+ lib._strategy_suppressed = False
274
+ lib._dg_reset()
275
+
276
+ lib.barstate.isfirst = True
277
+ lib.barstate.islast = False
278
+ lib.barstate.isconfirmed = True
279
+ lib.barstate.ishistory = True
280
+ lib.barstate.isrealtime = False
281
+ lib.barstate.isnew = False
282
+ lib.barstate.islastconfirmedhistory = False
283
+
284
+ from ..lib import request
285
+ request._reset_request_state()
286
+
287
+
288
+ def _resample_finer_security_feed(data_path: str, target_tf: str,
289
+ tmp_dir_holder: 'list[str]') -> str:
290
+ """Pre-resample a finer ``--security`` base feed to the security timeframe.
291
+
292
+ The native ``request.security()`` child exposes the feed bar at the confirmed
293
+ period boundary — correct only when the feed is already at the security
294
+ resolution (one bar per period). When a FINER base feed is mapped to an HTF
295
+ context (the documented "resampled from the chart base data" usage), the child
296
+ would otherwise expose a single raw sub-bar of the period instead of the
297
+ period aggregate, so ``request.security(.., open/high/low/close)`` diverges
298
+ from TradingView. This resamples the base feed to ``target_tf`` (via
299
+ :func:`aggregate_ohlcv`) so the child reads ONE aggregated bar per period and
300
+ every field matches TradingView.
301
+
302
+ :return: Path to a temporary resampled ``.ohlcv`` (with a cloned ``.toml``
303
+ sidecar) when the feed is finer than ``target_tf``; otherwise ``data_path``
304
+ unchanged (feed already at/above the security resolution, or no syminfo
305
+ metadata to drive the grid). Temp files live in a per-run directory whose
306
+ path is stored in ``tmp_dir_holder`` and removed at run teardown.
307
+ """
308
+ import hashlib
309
+ import tempfile
310
+ from .aggregator import aggregate_ohlcv
311
+ from .ohlcv_file import OHLCVReader
312
+ from .datetime import parse_timezone
313
+ from ..lib.timeframe import in_seconds
314
+
315
+ src = Path(data_path)
316
+ toml_path = src.with_suffix('.toml')
317
+ if not toml_path.exists():
318
+ # No syminfo metadata to drive the resample grid — keep the existing feed.
319
+ return data_path
320
+ try:
321
+ target_sec = in_seconds(target_tf)
322
+ except (ValueError, AssertionError):
323
+ return data_path
324
+ si = SymInfo.load_toml(toml_path)
325
+ # Decide the source resolution from the DECLARED sidecar period, not the empirical
326
+ # first-bar delta. An at-resolution feed whose first two bars are shorter than the
327
+ # nominal period — a monthly feed's 28-day Feb->Mar gap, or a session-bounded
328
+ # intraday feed — would otherwise look "finer" than ``target_tf`` and get needlessly
329
+ # resampled, inserting synthetic gap-fill bars that corrupt the security history.
330
+ # ``period`` is authoritative (it already drives ``source_tf`` for the aggregator);
331
+ # fall back to the measured interval only when it is missing/unparseable.
332
+ source_sec: int | None
333
+ if si.period:
334
+ try:
335
+ source_sec = in_seconds(si.period)
336
+ except (ValueError, AssertionError):
337
+ source_sec = None
338
+ else:
339
+ source_sec = None
340
+ if source_sec is None:
341
+ with OHLCVReader(src) as reader:
342
+ source_sec = reader.interval
343
+ if source_sec is None or source_sec >= target_sec:
344
+ # Feed already at (or coarser than) the security resolution: the child
345
+ # reads the period bar directly, no aggregation needed.
346
+ return data_path
347
+
348
+ try:
349
+ tz = parse_timezone(si.timezone) if si.timezone else None
350
+ except (ValueError, KeyError):
351
+ tz = None
352
+
353
+ if not tmp_dir_holder:
354
+ tmp_dir_holder.append(tempfile.mkdtemp(prefix='pyne_sec_resample_'))
355
+ # Hash the resolved source path into the name so two same-stem feeds from
356
+ # different directories never collide on one temp file.
357
+ src_key = hashlib.sha1(str(src.resolve()).encode()).hexdigest()[:12]
358
+ out = Path(tmp_dir_holder[0]) / f"{src.stem}__{src_key}__{target_tf}.ohlcv"
359
+ if out.exists():
360
+ # Another context already resampled this exact (source, target) earlier
361
+ # this run. Reuse it instead of re-running ``aggregate_ohlcv`` with
362
+ # ``truncate=True``, which would zero/rewrite a file a sibling security
363
+ # child has already mmap'ed (potential SIGBUS / wrong read). Spawning is
364
+ # serial on the chart process, so the file is fully written by now.
365
+ return str(out)
366
+
367
+ _, target_count = aggregate_ohlcv(
368
+ src, out, target_tf, tz=tz,
369
+ session_starts=si.session_starts or None,
370
+ opening_hours=si.opening_hours or None,
371
+ sym_type=si.type, source_tf=si.period,
372
+ )
373
+ if target_count == 0:
374
+ # An empty source (no bars) resamples to an empty file; swapping the child
375
+ # onto it would make ``request.security()`` read nothing and return ``na``.
376
+ # Keep the original feed and drop the empty temp so the reuse guard above
377
+ # never returns it later. (A single-record source does NOT reach here:
378
+ # ``aggregate_ohlcv`` emits its lone bar floored onto the target grid, which
379
+ # the child's ``size == 1`` ``load_htf_bar_opens`` path then confirms at the
380
+ # period boundary.)
381
+ out.unlink(missing_ok=True)
382
+ return data_path
383
+ # The resampled feed IS the security timeframe; the cloned sidecar keeps every
384
+ # other field (timezone, sessions, mintick, ...) so the child's syminfo and
385
+ # grid args stay correct.
386
+ si.period = target_tf
387
+ si.save_toml(out.with_suffix('.toml'))
388
+ return str(out)
389
+
390
+
391
+ @dataclass(frozen=True)
392
+ class SecurityRequirement:
393
+ """A single ``request.security()`` / ``request.security_lower_tf()`` data
394
+ dependency extracted statically from a script's ``__security_contexts__``.
395
+
396
+ :ivar sec_id: The transformer-assigned security context id.
397
+ :ivar symbol: Resolved symbol string, or ``None`` when it is only known at
398
+ runtime (computed from a variable/series/function parameter).
399
+ :ivar timeframe: Resolved timeframe string (``''`` already normalized to the
400
+ chart timeframe), or ``None`` when only known at runtime.
401
+ :ivar is_ltf: ``True`` for ``request.security_lower_tf()`` (lower timeframe).
402
+ :ivar ignore_invalid_symbol: ``True`` when the call passes
403
+ ``ignore_invalid_symbol=true`` (missing data is tolerated, not an error).
404
+ :ivar from_library: ``True`` when the context comes from an imported library
405
+ module rather than the main script.
406
+ :ivar has_security_mapping: ``True`` when a matching ``--security`` key was
407
+ provided for this symbol/timeframe.
408
+ :ivar has_global_map: ``True`` when the global ``config/symbol_map.toml``
409
+ maps this symbol (optionally per-timeframe).
410
+ :ivar mapped_provider: Provider name of the global-map hit, or ``None``.
411
+ :ivar mapped_native_symbol: Provider-native symbol of the global-map hit,
412
+ or ``None``.
413
+ :ivar mapped_file: The ``.ohlcv`` path derived from the global-map hit via
414
+ ``ProviderPlugin.get_ohlcv_path`` (backtest), or ``None``.
415
+ :ivar mapped_file_exists: ``True`` when :attr:`mapped_file` exists on disk.
416
+ :ivar download_suggestion: A ready-to-run ``pyne data download`` command for
417
+ the mapped-but-missing file, or ``None``.
418
+ :ivar file_suggestions: Existing ``.ohlcv`` file stems in the data dir whose
419
+ ticker matches this symbol (ignoring the exchange prefix) — candidate
420
+ sources when there is no global-map hit.
421
+ """
422
+ sec_id: str
423
+ symbol: str | None
424
+ timeframe: str | None
425
+ is_ltf: bool
426
+ ignore_invalid_symbol: bool
427
+ from_library: bool
428
+ has_security_mapping: bool
429
+ has_global_map: bool = False
430
+ mapped_provider: str | None = None
431
+ mapped_native_symbol: str | None = None
432
+ mapped_file: str | None = None
433
+ mapped_file_exists: bool = False
434
+ download_suggestion: str | None = None
435
+ file_suggestions: list[str] = dataclasses_field(default_factory=list)
436
+
437
+
438
+ @dataclass(frozen=True)
439
+ class DataRequirements:
440
+ """Classified data dependencies of a script, relative to a chart symbol/TF.
441
+
442
+ Each bucket holds the :class:`SecurityRequirement` entries that fall into it.
443
+ See :meth:`ScriptRunner.list_data_requirements` for the classification rules.
444
+ """
445
+ chart_symbol: str
446
+ chart_tf: str
447
+ chart_main: list[SecurityRequirement]
448
+ same_symbol_other_tf: list[SecurityRequirement]
449
+ cross_symbol: list[SecurityRequirement]
450
+ dynamic: list[SecurityRequirement]
451
+
452
+
453
+ class ScriptRunner:
454
+ """
455
+ Script runner
456
+ """
457
+
458
+ __slots__ = ('script_module', 'script', 'ohlcv_iter', 'syminfo', 'update_syminfo_every_run',
459
+ 'bar_index', 'tz', 'plot_writer', 'strat_writer', 'trades_writer', 'last_bar_index',
460
+ 'last_bar_time',
461
+ 'viz_writer', 'viz_journal', '_viz_shadow', 'viz_events',
462
+ 'equity_curve', 'first_price', 'last_price', 'stats',
463
+ '_script_path', '_security_data', '_magnifier_iter', '_magnifier_source_tf',
464
+ '_chart_provider_name', '_chart_provider_instance', '_chart_data_path',
465
+ '_time_from', '_sec_syminfos', '_signal_rate_sources_fn',
466
+ '_broker_plugin', '_order_sync_engine', '_broker_event_loop',
467
+ '_engine_event_stream_future',
468
+ '_broker_store_ctx', '_log_ohlcv', '_price_decimals',
469
+ '_round_decimals', '_config_dir', '_symbol_map',
470
+ 'broker_balance', '_sim_logged_open_ids')
471
+
472
+ # noinspection PyProtectedMember
473
+ def __init__(self, script_path: Path, ohlcv_iter: Iterable[OHLCV], syminfo: SymInfo, *,
474
+ plot_path: Path | None = None, strat_path: Path | None = None,
475
+ trade_path: Path | None = None,
476
+ viz_path: Path | None = None, viz_journal: bool = False,
477
+ update_syminfo_every_run: bool = False, last_bar_index=0,
478
+ last_bar_time: int | None = None,
479
+ inputs: dict[str, Any] | None = None,
480
+ security_data: 'dict[str, str | Path | PluginSymbol] | None' = None,
481
+ magnifier_iter: Iterable[OHLCV] | None = None,
482
+ magnifier_source_tf: str | None = None,
483
+ broker_plugin: 'BrokerPlugin | None' = None,
484
+ broker_event_loop: 'asyncio.AbstractEventLoop | None' = None,
485
+ broker_store_ctx: 'RunContext | None' = None,
486
+ log_ohlcv: bool = False,
487
+ chart_provider_name: str | None = None,
488
+ chart_provider_instance: Any = None,
489
+ time_from: datetime | None = None,
490
+ chart_data_path: Path | None = None,
491
+ config_dir: Path | None = None):
492
+ """
493
+ Initialize the script runner
494
+
495
+ :param script_path: The path to the script to run
496
+ :param ohlcv_iter: Iterator of OHLCV data
497
+ :param syminfo: Symbol information
498
+ :param plot_path: Path to save the plot data
499
+ :param strat_path: Path to save the strategy results
500
+ :param trade_path: Path to save the trade data of the strategy
501
+ :param viz_path: Path to write the plot/drawing visual data (NDJSON). ``None`` disables
502
+ file output; a ``viz_events`` callback still receives journal events
503
+ when ``viz_journal`` is set.
504
+ :param viz_journal: If true, diff the live drawings every bar and emit
505
+ create/update/delete events (to the file and/or ``viz_events``)
506
+ :param update_syminfo_every_run: If it is needed to update the syminfo lib in every run,
507
+ needed for parallel script executions
508
+ :param last_bar_index: Last bar index, the index of the last bar of the historical data
509
+ :param last_bar_time: UNIX time (ms) of the last bar of the historical data. Pine fixes
510
+ ``last_bar_time`` on historical bars to the chart's final bar;
511
+ ``None`` falls back to tracking the current bar (live semantics)
512
+ :param inputs: Optional dictionary of input values to pass to the script,
513
+ overrides values from .toml files
514
+ :param security_data: Optional dict mapping ``"[SYMBOL:]TIMEFRAME"`` keys to
515
+ OHLCV file paths for request.security() contexts.
516
+ Examples: ``{"1D": "path/to/daily.ohlcv"}`` or
517
+ ``{"AAPL:1H": "path/to/aapl_1h.ohlcv"}``
518
+ :param magnifier_iter: Optional sub-timeframe OHLCV iterator for bar magnifier mode.
519
+ When provided with use_bar_magnifier=true, order fills are checked
520
+ against each sub-bar for more accurate backtesting.
521
+ :param magnifier_source_tf: Timeframe string of the ``magnifier_iter`` data —
522
+ multi-period (nD/nW/nM) chart timeframes resolve a sub-bar
523
+ by its last instant (see the ``resampler`` module docs).
524
+ :param broker_plugin: If set, the runner operates in **broker (live trading) mode**:
525
+ ``script.position`` is replaced by a :class:`BrokerPosition`,
526
+ ``strategy.*`` orders are dispatched through an
527
+ :class:`OrderSyncEngine`, and the simulator's order processing
528
+ is bypassed. The plugin also drives the OHLCV stream
529
+ (a :class:`BrokerPlugin` extends :class:`LiveProviderPlugin`).
530
+ :param broker_event_loop: The shared ``asyncio`` event loop on which the broker plugin
531
+ runs. Passed to the :class:`OrderSyncEngine` so that
532
+ broker coroutines can be awaited from the runner thread
533
+ via ``run_coroutine_threadsafe``.
534
+ :param broker_store_ctx: Optional :class:`RunContext` from the unified
535
+ :class:`BrokerStore`. When provided the engine persists
536
+ envelope identity and parked-verification entries through
537
+ it, and the runner heartbeats this context on every sync
538
+ so crash detection works. ``None`` means no persistence
539
+ (tests, backtests) — the ``run_tag`` is then derived
540
+ locally from the plugin's ``account_id``. Caller owns
541
+ the lifecycle: ``close()`` on shutdown.
542
+ :raises ImportError: If the script does not have a 'main' function
543
+ :raises ImportError: If the 'main' function is not decorated with @script.[indicator|strategy|library]
544
+ :raises OSError: If the plot file could not be opened
545
+ """
546
+ self._script_path = script_path
547
+ self._security_data = security_data or {}
548
+ self._magnifier_iter = magnifier_iter
549
+ self._magnifier_source_tf = magnifier_source_tf
550
+ self._log_ohlcv = log_ohlcv
551
+ # Chart provider hooks — used in live mode by ``_resolve_security_data``
552
+ # to translate Pine-style cross-symbol security keys to plugin-native
553
+ # symbols (via ``provider.resolve_symbol``) when the user did not
554
+ # supply an explicit ``--security`` mapping.
555
+ self._chart_provider_name: str | None = chart_provider_name
556
+ self._chart_provider_instance: Any = chart_provider_instance
557
+ # Chart's own ``.ohlcv`` path (backtest/file mode). Used as the source
558
+ # feed for a ``ticker.heikinashi()`` request on the chart's own symbol
559
+ # when no explicit ``--security`` mapping supplies one — the runner is
560
+ # otherwise handed only an OHLCV iterator, not a file the security child
561
+ # can open. ``None`` in live/provider streaming mode (no static file).
562
+ self._chart_data_path: Path | None = chart_data_path
563
+ # Global workdir symbol map (``config/symbol_map.toml``): translates
564
+ # TradingView-style ``request.security()`` symbols to provider-native
565
+ # ones for backtest file resolution and live ``PluginSymbol`` building.
566
+ # A missing/malformed file yields an empty map (never crashes a run).
567
+ from .symbol_map import SymbolMap
568
+ self._config_dir: Path | None = config_dir
569
+ self._symbol_map: SymbolMap = SymbolMap.load(config_dir)
570
+ # Expose the global map + running provider name on the chart provider
571
+ # so its ``resolve_symbol`` can fall back to the global map (gated on a
572
+ # matching provider) after the plugin's own ``config.symbol_map``.
573
+ if chart_provider_instance is not None:
574
+ try:
575
+ chart_provider_instance.global_symbol_map = self._symbol_map
576
+ chart_provider_instance.provider_name = chart_provider_name
577
+ except (AttributeError, TypeError):
578
+ pass
579
+ # Chart-side ``--from`` (already datetime). Forwarded into every
580
+ # live-mode :class:`PluginSymbol` so each security context's warmup
581
+ # window inherits the chart's look-back range instead of the
582
+ # hard-coded subprocess default.
583
+ self._time_from: datetime | None = time_from
584
+ # Cache for pre-fetched ``SymInfo`` per live-mode security sec_id —
585
+ # populated by ``_prefetch_sec_syminfos`` and consumed by the
586
+ # currency-rate plumbing on the chart side. Empty in backtest mode.
587
+ self._sec_syminfos: 'dict[str, SymInfo]' = {}
588
+ # Optional per-bar driver for ``__auto_rate_*`` rate-source
589
+ # subprocesses. Installed by ``create_chart_protocol`` when any
590
+ # auto-rate sec_ids exist; left as ``None`` for backtests / runs
591
+ # without ``currency=`` conversions, so the bar loop short-circuits.
592
+ self._signal_rate_sources_fn: 'Callable[[], None] | None' = None
593
+
594
+ # Import lib module to set syminfo properties before script import
595
+ from .. import lib
596
+
597
+ # Set syminfo properties BEFORE importing the script
598
+ # This ensures that timestamp() calls in default parameters use the correct timezone
599
+ _set_lib_syminfo_properties(syminfo)
600
+
601
+ # Set programmatic inputs before script import so they override .toml values
602
+ if inputs:
603
+ from .script import _programmatic_inputs
604
+ _programmatic_inputs.update(inputs)
605
+
606
+ # Now import the script (default parameters will use correct timezone)
607
+ self.script_module = import_script(script_path)
608
+
609
+ if not hasattr(self.script_module.main, 'script'):
610
+ raise ImportError(f"The 'main' function must be decorated with "
611
+ f"@script.[indicator|strategy|library] to run!")
612
+
613
+ self.script: script = self.script_module.main.script
614
+
615
+ # Broker (live trading) mode setup.
616
+ # Done before ohlcv_iter is consumed so the engine is ready before run_iter.
617
+ self._broker_plugin: 'BrokerPlugin | None' = broker_plugin
618
+ self._broker_event_loop: 'asyncio.AbstractEventLoop | None' = broker_event_loop
619
+ self._broker_store_ctx: 'RunContext | None' = broker_store_ctx
620
+ self._order_sync_engine: 'OrderSyncEngine | None' = None
621
+ self._engine_event_stream_future: Any = None
622
+ self.broker_balance: dict[str, float] | None = None
623
+ # Identities of open SimPosition trades already announced via
624
+ # ``[SIM]`` logging — so each fill is narrated once in paper mode.
625
+ self._sim_logged_open_ids: set[int] = set()
626
+ if broker_plugin is not None:
627
+ from pynecore.core.broker.position import BrokerPosition
628
+ from pynecore.core.broker.run_identity import RunIdentity
629
+ from pynecore.core.broker.sync_engine import OrderSyncEngine
630
+ # Swap the simulator position for a live tracker. The
631
+ # @script.strategy(...) decorator already attached a SimPosition;
632
+ # in live broker mode the exchange is authoritative, so the
633
+ # simulator is dropped entirely.
634
+ self.script.position = BrokerPosition()
635
+ if broker_store_ctx is not None:
636
+ # Persistence-backed run: the CLI already opened a RunContext
637
+ # via BrokerStore.open_run(), which computed the canonical
638
+ # run_tag from the full RunIdentity.
639
+ run_tag = broker_store_ctx.run_tag
640
+ else:
641
+ # No-persistence fallback (tests, single-shot backtests):
642
+ # derive the run_tag locally so every sub-path still has a
643
+ # stable id. The fallback identity uses the plugin's
644
+ # ``account_id`` (``"default"`` when the plugin has not been
645
+ # authenticated), matching what the persistence path would
646
+ # compute.
647
+ identity = RunIdentity(
648
+ strategy_id=script_path.stem,
649
+ symbol=str(syminfo.ticker),
650
+ timeframe=str(syminfo.period or ""),
651
+ account_id=broker_plugin.account_id,
652
+ label=None,
653
+ )
654
+ run_tag = identity.make_run_tag(
655
+ script_path.read_text(encoding='utf-8'),
656
+ )
657
+ self._order_sync_engine = OrderSyncEngine(
658
+ broker=broker_plugin,
659
+ position=self.script.position, # type: ignore[arg-type]
660
+ symbol=str(syminfo.ticker),
661
+ run_tag=run_tag,
662
+ event_loop=broker_event_loop,
663
+ mintick=float(syminfo.mintick) if syminfo.mintick else 0.01,
664
+ # Tick-grid factors for the native fail-safe rounding
665
+ # (mintick == minmove / pricescale). Only forwarded when the
666
+ # symbol carries a real mintick; otherwise the ``0`` sentinel
667
+ # keeps the manager from snapping levels to the synthetic
668
+ # 0.01 fallback grid above.
669
+ minmove=float(syminfo.minmove) if syminfo.mintick else 0.0,
670
+ pricescale=int(syminfo.pricescale) if syminfo.mintick else 0,
671
+ store_ctx=broker_store_ctx,
672
+ # Mirror exchange position state every bar. The exchange is
673
+ # the source of truth — without per-sync reconciliation, an
674
+ # externally-closed position (manual web-UI close, broker
675
+ # liquidation) would never propagate back to ``position.size``,
676
+ # leaving Pine convinced the bot is still in a trade and
677
+ # blocking all subsequent entries.
678
+ reconcile_every_n_syncs=1,
679
+ )
680
+ # Plugin-side access to the storage run: the Capital.com plugin
681
+ # uses this for ``find_by_ref`` lookups, order upserts and audit
682
+ # event logging without having the context threaded through every
683
+ # ``execute_*`` signature.
684
+ broker_plugin.store_ctx = broker_store_ctx
685
+
686
+ # §2.6.7 native fail-safe actuator. The engine's
687
+ # ``drive_native_failsafe`` (run once per ``sync``) drains the
688
+ # worst-SL state machine into this dispatcher; without it the
689
+ # fail-safe is state-only and no protective stop is ever placed
690
+ # at the broker — for single-row partial brackets too. The
691
+ # dispatcher is a pure PUT-or-raise actuator: the engine records
692
+ # a put-success on a normal return and a put-failure on any
693
+ # exception (see ``OrderSyncEngine.set_native_bracket_dispatcher``),
694
+ # so this closure must not touch the record_* hooks. The plugin
695
+ # PUT is async and must run on the broker loop, so it is marshalled
696
+ # through the engine's own ``_run_async`` (identical loop + timeout
697
+ # to every other broker call). Only wired when the plugin actually
698
+ # provides the actuator — other plugins simply stay state-only.
699
+ _failsafe_publish = getattr(
700
+ broker_plugin, 'publish_native_failsafe_sl', None,
701
+ )
702
+ if _failsafe_publish is not None:
703
+ _engine = cast('OrderSyncEngine', self._order_sync_engine)
704
+
705
+ # noinspection PyProtectedMember
706
+ def _native_failsafe_dispatcher(snapshot):
707
+ _engine._run_async(_failsafe_publish(snapshot))
708
+
709
+ _engine.set_native_bracket_dispatcher(
710
+ _native_failsafe_dispatcher,
711
+ )
712
+
713
+ # §2.6.7 native fail-safe recovery feed (the reverse channel of
714
+ # the dispatcher above). The plugin's reconcile pass observes the
715
+ # broker-side bracket levels per live position; this sink routes
716
+ # them into the engine so a parent stuck in DEGRADING — a restart
717
+ # replay, or a PUT retry whose success the broker could not confirm
718
+ # directly — flips back to HEALTHY once the desired worst-SL is
719
+ # observed in place. Without it the stale-window timer escalates
720
+ # DEGRADING -> DEGRADED in seconds and blocks new entries / brackets
721
+ # until a manual reset. The reconcile pass runs on the broker
722
+ # event-loop thread, so the sink is the engine's thread-safe
723
+ # ``enqueue_native_bracket_observed`` (it queues; the main thread
724
+ # applies it in ``drive_native_failsafe``) — calling
725
+ # ``record_native_bracket_observed`` directly here would race the
726
+ # main-thread worst-SL machinery. Installed unconditionally: the
727
+ # attribute defaults to ``None`` on the base, plugins opt in by
728
+ # calling it, and the engine drops snapshots for refs it does not
729
+ # track at drain time.
730
+ broker_plugin.native_failsafe_observed_sink = (
731
+ cast('OrderSyncEngine', self._order_sync_engine).enqueue_native_bracket_observed
732
+ )
733
+
734
+ # Quarantine latch for the disappearance tracking's ``stop`` /
735
+ # ``stop_and_cancel`` policies: trading stops but the process
736
+ # (and the plugin's event stream) stays alive. Wired
737
+ # unconditionally, like the observed sink above — the engine
738
+ # latch is idempotent and thread-safe from the broker
739
+ # event-loop thread; plugins without disappearance tracking
740
+ # simply never call it. Without this wiring the tracker falls
741
+ # back to the process-exiting halt.
742
+ broker_plugin.quarantine_sink = (
743
+ cast('OrderSyncEngine', self._order_sync_engine).record_quarantine
744
+ )
745
+
746
+ # Native bulk-cancel expected-cancel arm. A plugin whose
747
+ # ``execute_cancel_all`` calls a single native endpoint (e.g. Bybit
748
+ # ``POST /v5/order/cancel-all``) bypasses the engine's per-order
749
+ # ``_dispatch_cancel``; without this hook the venue's follow-up
750
+ # ``CANCELLED`` pushes would be misread as external cancels and trip
751
+ # the ``on_unexpected_cancel`` quarantine on the engine's OWN bulk
752
+ # cancel. The plugin calls this before the venue round-trip; the
753
+ # marker rides the thread-safe event queue and is applied on the main
754
+ # thread ahead of those pushes. Installed unconditionally — plugins
755
+ # without a native bulk cancel never call it.
756
+ broker_plugin.native_cancel_all_expected_sink = (
757
+ cast('OrderSyncEngine', self._order_sync_engine).enqueue_native_cancel_all_expected
758
+ )
759
+
760
+ self.ohlcv_iter = ohlcv_iter
761
+ self.syminfo = syminfo
762
+ self.update_syminfo_every_run = update_syminfo_every_run
763
+ self.last_bar_index = last_bar_index
764
+ self.last_bar_time = last_bar_time
765
+ # Pre-increment scheme: bumped at the start of each bar's processing
766
+ # (warmup, live, security loops). Starting at -1 keeps the first
767
+ # processed bar at index 0 — matches Pine ``bar_index`` semantics.
768
+ self.bar_index = -1
769
+
770
+ # Precompute price decimals from ``syminfo.mintick`` so live OHLCV
771
+ # log lines keep a constant column width (fix-width ``%.*f``). The
772
+ # Pine ``format.mintick`` path in ``lib.string.tostring`` strips
773
+ # trailing zeros and would jitter the width, which is why we don't
774
+ # route through it here.
775
+ #
776
+ # The decimal count comes from ``str(mintick)`` (Python's shortest
777
+ # round-trip repr), so ``0.05`` yields ``2`` without exposing float
778
+ # dust. ``pricescale`` cannot be used: for fractional tick grids the
779
+ # generated symbol info stores ``pricescale = round(1 / mintick)``
780
+ # with ``minmove = 1`` (e.g. ``mintick=0.05`` -> ``pricescale=20``),
781
+ # so ``len(str(pricescale)) - 1`` would under-count decimals. When
782
+ # ``mintick`` is missing/zero we fall back to 2 decimals (the broker
783
+ # path uses a synthetic ``0.01`` tick for the same case).
784
+ _mintick = getattr(syminfo, 'mintick', 0.0) or 0.0
785
+ self._price_decimals = mintick_decimals(_mintick) if _mintick > 0 else 2
786
+ # Decimals used to snap OHLC to the mintick grid (see ``_round_price``).
787
+ # ``None`` when the symbol carries no real mintick, so rounding falls
788
+ # back to the magnitude-relative significant-digit heuristic.
789
+ self._round_decimals = mintick_decimals(_mintick) if _mintick > 0 else None
790
+
791
+ self.tz = lib._parse_timezone(syminfo.timezone)
792
+
793
+ # Initialize tracking variables for statistics
794
+ self.equity_curve: list[float] = []
795
+ self.first_price: float | None = None
796
+ self.last_price: float | None = None
797
+
798
+ # Final strategy statistics, cached after run() so callers (e.g. `pyne
799
+ # optimize`) can read runner.stats without a strat CSV writer.
800
+ self.stats: StrategyStatistics | None = None
801
+
802
+ self.plot_writer = CSVWriter(
803
+ plot_path, float_fmt=f".8g"
804
+ ) if plot_path else None
805
+ # Visual data (plot styles + drawings) NDJSON writer. Journaling can also
806
+ # run without a file: ``_viz_shadow`` drives the per-bar diff whose events
807
+ # are handed to the ``viz_events`` callback (set by the caller).
808
+ self.viz_writer = VizWriter(viz_path) if viz_path else None
809
+ self.viz_journal = viz_journal
810
+ self._viz_shadow: dict | None = {} if viz_journal else None
811
+ self.viz_events: Callable[[list[dict]], None] | None = None
812
+ self.strat_writer = CSVWriter(strat_path, headers=(
813
+ "Metric",
814
+ f"All {syminfo.currency}", "All %",
815
+ f"Long {syminfo.currency}", "Long %",
816
+ f"Short {syminfo.currency}", "Short %",
817
+ )) if strat_path else None
818
+ self.trades_writer = CSVWriter(trade_path, headers=(
819
+ "Trade #", "Bar Index", "Type", "Signal", "Date/Time", f"Price {syminfo.currency}",
820
+ "Contracts", f"Profit {syminfo.currency}", "Profit %", f"Cumulative profit {syminfo.currency}",
821
+ "Cumulative profit %", f"Run-up {syminfo.currency}", "Run-up %", f"Drawdown {syminfo.currency}",
822
+ "Drawdown %",
823
+ )) if trade_path else None
824
+
825
+ # === Broker startup ====================================================
826
+
827
+ # noinspection PyProtectedMember
828
+ def start_broker(self) -> None:
829
+ """Start broker-side I/O after construction.
830
+
831
+ Two side effects, both intentionally kept out of ``__init__`` so the
832
+ caller can finish ``Loading PyneCore`` (script import + runner setup)
833
+ before any broker logs appear:
834
+
835
+ 1. Schedule :meth:`OrderSyncEngine.run_event_stream` on the broker
836
+ event loop. Without this task, fill events never reach
837
+ :meth:`BrokerPosition.record_fill` and ``position.size`` stays
838
+ at 0 — the script then keeps re-entering on every flat-only
839
+ branch tick because it never sees its own already-open position.
840
+ 2. Run the startup reconcile. Adopts the exchange's authoritative
841
+ state (``get_position`` → ``BrokerPosition.size``/``avg_price``,
842
+ ``get_open_orders`` → ``_order_mapping``) before the first bar
843
+ runs. Without this, a fresh process restart with an open
844
+ exchange position would see ``position_size == 0`` in Pine and
845
+ re-enter — opening a *second* position alongside the existing
846
+ one.
847
+
848
+ No-op when not in broker mode.
849
+ """
850
+ if self._order_sync_engine is None:
851
+ return
852
+ engine = cast('OrderSyncEngine', self._order_sync_engine)
853
+ # Plugin ``connect()`` (run during ``live_ohlcv_generator``) may have
854
+ # mutated the ``envelopes`` / ``pending_verifications`` tables via
855
+ # ``_retire_startup_orphans``. The engine cached both replays in its
856
+ # ``__init__``, so refresh the in-memory anchors here BEFORE the
857
+ # first dispatch to avoid popping a stale ``bar_ts_ms`` that resurrects
858
+ # a just-retired ``client_order_id`` onto a row whose ``closed_ts_ms``
859
+ # is still set.
860
+ engine.refresh_anchors_from_store()
861
+ loop = self._broker_event_loop
862
+ if loop is not None:
863
+ self._engine_event_stream_future = asyncio.run_coroutine_threadsafe(
864
+ engine.run_event_stream(),
865
+ loop,
866
+ )
867
+ # Defensive-close pending markers from prior process instances
868
+ # must be re-armed (or dropped, if the FILL already settled)
869
+ # BEFORE the startup reconcile so the reconcile snapshot reflects
870
+ # the in-flight-close set the engine should preserve through
871
+ # ``_active_intents``. Without the replay a fresh process could
872
+ # treat a flat exchange as an external flatten and re-enter on
873
+ # the next bar against a position the previous instance was
874
+ # already closing defensively.
875
+ engine._replay_pending_defensive_closes()
876
+ engine.reconcile()
877
+
878
+ # === Order-processing dispatch =========================================
879
+
880
+ def _broker_sync(self) -> None:
881
+ """Run one engine sync, parking a recoverable broker connection loss.
882
+
883
+ The broker plugin re-authorizes a mid-session account-auth / connection
884
+ loss in-band; only a fully failed recovery surfaces
885
+ :class:`ExchangeConnectionError` from dispatch. Park the cycle and retry
886
+ on the next bar — the COID-idempotent diff re-dispatches safely — rather
887
+ than crashing the live run. A deliberate halt
888
+ (:class:`BrokerManualInterventionError`) is NOT caught here and still
889
+ stops the bot. A dispatch-bridge ``TimeoutError`` (a broker call wedged
890
+ past ``execute_timeout``) is deliberately NOT parked: the engine's
891
+ ``run_coroutine_threadsafe(...).result(timeout)`` does not cancel the
892
+ still-running coroutine, so the in-flight order may yet land — silently
893
+ re-dispatching it next bar could double-fill a close/amend (which carry
894
+ no ``client_order_id`` and so are not exchange-deduped). It stays fatal
895
+ (the pre-existing behaviour), which is the safe choice for a wedged
896
+ broker. A slow but recoverable re-auth instead surfaces as the
897
+ ``ExchangeConnectionError`` above, bounded by ``_REAUTH_TIMEOUT``.
898
+ """
899
+ try:
900
+ cast('OrderSyncEngine', self._order_sync_engine).sync(
901
+ int(lib.last_bar_time),
902
+ last_price=_close_price_or_none(),
903
+ )
904
+ except ExchangeConnectionError as e:
905
+ broker_warning(
906
+ "broker sync skipped after connection error: %s — "
907
+ "retrying next bar", e,
908
+ )
909
+ return
910
+ # Heartbeat the storage run on every sync — the RunContext rate-limits
911
+ # internally to ``HEARTBEAT_INTERVAL_MS``, so the actual UPDATE fires at
912
+ # most once per minute regardless of sync frequency. SIGKILL / OOM then
913
+ # gets cleaned on the next open_run() via the stale-run threshold.
914
+ if self._broker_store_ctx is not None:
915
+ self._broker_store_ctx.heartbeat()
916
+
917
+ def _process_orders(self, position) -> None:
918
+ """Run one order-processing step.
919
+
920
+ In backtest mode this invokes the :class:`SimPosition` simulator
921
+ (OHLC fill detection, slippage, OCA, margin). In broker mode it
922
+ hands the pending Pine order book to the :class:`OrderSyncEngine`,
923
+ which dispatches real exchange calls and routes any fills that
924
+ arrived asynchronously through :meth:`BrokerPosition.record_fill`.
925
+ """
926
+ if self._order_sync_engine is not None:
927
+ self._broker_sync()
928
+ else:
929
+ position.process_orders()
930
+
931
+ # noinspection PyProtectedMember
932
+ def _write_viz_bar(self, candle) -> None:
933
+ """Emit the current bar's visual data (values + colors + journal events).
934
+
935
+ Reads the just-populated ``lib._plot_data`` / ``lib._viz_dyn`` and the
936
+ current-bar time (``lib._time``, already in milliseconds). Must be called
937
+ after the script body ran and before the per-bar viz-state is cleared.
938
+
939
+ :param candle: The current OHLCV bar (kept for signature parity; time is
940
+ taken from ``lib._time`` which the runner already set).
941
+ """
942
+ if self.viz_writer is None and self._viz_shadow is None:
943
+ return
944
+ if self.viz_writer is not None:
945
+ self.viz_writer.write_bar(self.bar_index, lib._time, lib._plot_data, lib._viz_dyn)
946
+ if self._viz_shadow is not None:
947
+ events = viz.journal_diff(self._viz_shadow, self.bar_index)
948
+ if self.viz_writer is not None:
949
+ self.viz_writer.write_events(events)
950
+ if self.viz_events is not None:
951
+ self.viz_events(events)
952
+
953
+ def _process_orders_magnified(self, position, sub_bars, candle) -> None:
954
+ """Backtest sub-bar order processing; in broker mode, the exchange
955
+ is the source of truth — magnification is irrelevant and the engine
956
+ runs a plain sync."""
957
+ if self._order_sync_engine is not None:
958
+ self._broker_sync()
959
+ else:
960
+ position.process_orders_magnified(sub_bars, candle)
961
+
962
+ def _log_sim_fills(self, position) -> None:
963
+ """Narrate paper-trading fills in ``--live`` mode without a broker.
964
+
965
+ The :class:`SimPosition` fills orders locally and silently. This is the
966
+ simulator counterpart of the ``[BROKER]`` order narration: ``[SIM]``
967
+ lines so the operator sees entries and exits as they happen. Exits come
968
+ from ``new_closed_trades`` (refreshed by the simulator every bar);
969
+ entries are announced once per open trade, tracked by object identity.
970
+
971
+ :param position: The active :class:`SimPosition`.
972
+ """
973
+ d = self._price_decimals
974
+ for t in position.new_closed_trades:
975
+ side = "long" if t.size > 0 else "short"
976
+ sim_info(
977
+ "EXIT %s %s qty=%g entry=%.*f exit=%.*f pnl=%+.2f",
978
+ side, t.exit_id or t.entry_id or "", abs(t.size),
979
+ d, float(t.entry_price), d, float(t.exit_price), float(t.profit),
980
+ )
981
+ current_ids: set[int] = set()
982
+ for t in position.open_trades:
983
+ current_ids.add(id(t))
984
+ if id(t) not in self._sim_logged_open_ids:
985
+ side = "long" if t.size > 0 else "short"
986
+ sim_info(
987
+ "ENTRY %s %s qty=%g @ %.*f",
988
+ side, t.entry_id or "", abs(t.size), d, float(t.entry_price),
989
+ )
990
+ self._sim_logged_open_ids = current_ids
991
+
992
+ def _process_deferred_margin_call(self, position) -> None:
993
+ """Simulator-only. The exchange handles margin in broker mode, so
994
+ any deferred margin handling is a no-op there."""
995
+ if self._order_sync_engine is None:
996
+ position.process_deferred_margin_call()
997
+
998
+ @property
999
+ def _broker_mode(self) -> bool:
1000
+ return self._order_sync_engine is not None
1001
+
1002
+ @property
1003
+ def plot_meta(self) -> dict:
1004
+ """The registered plot-family metadata for the current/last run.
1005
+
1006
+ Kept live after the run (drawing/meta state is reset only at run-start),
1007
+ so callers can introspect ``{id -> PlotMeta}`` programmatically.
1008
+ """
1009
+ return lib._plot_meta
1010
+
1011
+ @staticmethod
1012
+ def drawings() -> dict:
1013
+ """Full snapshot of the live drawing objects (lines/labels/boxes/...)."""
1014
+ return viz.drawings_snapshot()
1015
+
1016
+ @property
1017
+ def broker_position_snapshot(self) -> 'Any | None':
1018
+ if self._order_sync_engine is None:
1019
+ return None
1020
+ return cast('OrderSyncEngine', self._order_sync_engine).exchange_position
1021
+
1022
+ # noinspection PyProtectedMember
1023
+ def run_iter(self, on_progress: Callable[[datetime], None] | None = None,
1024
+ on_tick: Callable[[OHLCV], None] | None = None) \
1025
+ -> Iterator[tuple[OHLCV, dict[str, Any]] | tuple[OHLCV, dict[str, Any], list['Trade']]]:
1026
+ """
1027
+ Run the script on the data
1028
+
1029
+ :param on_progress: Callback to call on every iteration
1030
+ :param on_tick: Optional per-update live callback (see :meth:`run`).
1031
+ :return: Return a dictionary with all data the sctipt plotted
1032
+ :raises AssertionError: If the 'main' function does not return a dictionary
1033
+ """
1034
+ from .. import lib
1035
+ from ..lib import _parse_timezone, barstate, string
1036
+ from pynecore.core import instance_state
1037
+ from . import script
1038
+
1039
+ is_strat = self.script.script_type == script_type.strategy
1040
+
1041
+ # Reset bar_index — pre-increment scheme starts at -1.
1042
+ self.bar_index = -1
1043
+ # Drop function instances left over from a previous run
1044
+ instance_state.reset()
1045
+
1046
+ # Set script data
1047
+ lib._script = self.script # Store script object in lib
1048
+
1049
+ # Broker mode: refuse to start if the script needs capabilities the
1050
+ # exchange doesn't offer. Fail fast — never on the first bar.
1051
+ if self._broker_plugin is not None:
1052
+ from pynecore.core.broker.validation import validate_at_startup
1053
+ from pynecore.core.broker.exceptions import (
1054
+ AuthenticationError,
1055
+ ExchangeCapabilityError,
1056
+ )
1057
+ caps = self._broker_plugin.get_capabilities()
1058
+ reqs = getattr(self.script, '_broker_requirements', None)
1059
+ if reqs is not None:
1060
+ pyramiding = int(getattr(self.script, 'pyramiding', 1) or 1)
1061
+ errors = validate_at_startup(cast('ScriptRequirements', reqs), caps, pyramiding=pyramiding)
1062
+ if errors:
1063
+ raise ExchangeCapabilityError(
1064
+ "Script requirements not met by exchange:\n"
1065
+ + "\n".join(f" - {e}" for e in errors)
1066
+ )
1067
+
1068
+ # Auth check: fail fast on bad credentials rather than on the
1069
+ # first order attempt. A single get_balance() call is cheap and
1070
+ # every exchange supports it. An AuthenticationError here is
1071
+ # terminal — reconnect can never recover wrong keys.
1072
+ coro = self._broker_plugin.get_balance()
1073
+ try:
1074
+ if self._broker_event_loop is None:
1075
+ balance = asyncio.run(coro)
1076
+ else:
1077
+ balance = asyncio.run_coroutine_threadsafe(
1078
+ coro, self._broker_event_loop,
1079
+ ).result(timeout=30.0)
1080
+ except AuthenticationError as exc:
1081
+ raise AuthenticationError(
1082
+ "Broker authentication failed at startup — cannot begin "
1083
+ f"trading: {exc.reason}",
1084
+ reason=exc.reason,
1085
+ ) from exc
1086
+
1087
+ # Confirm demo/live authentication and account identity at INFO
1088
+ # without dumping every asset balance into the durable transcript.
1089
+ # A multi-asset account prints its complete equity mapping here,
1090
+ # which is noise for the operator and needlessly exposes the full
1091
+ # balance sheet; the detailed snapshot stays available at DEBUG.
1092
+ broker_info(
1093
+ "authenticated: plugin=%s account=%s",
1094
+ type(self._broker_plugin).__name__,
1095
+ self._broker_plugin.account_id,
1096
+ )
1097
+ broker_debug("account equity snapshot: %s", balance)
1098
+ self.broker_balance = balance
1099
+
1100
+ # Update syminfo lib properties if needed
1101
+ if not self.update_syminfo_every_run:
1102
+ _set_lib_syminfo_properties(self.syminfo)
1103
+ self.tz = _parse_timezone(lib.syminfo.timezone)
1104
+
1105
+ # Open plot writer if we have one
1106
+ if self.plot_writer:
1107
+ self.plot_writer.open()
1108
+
1109
+ # Open the viz writer and emit the header (syminfo/script are set up above)
1110
+ if self.viz_writer is not None:
1111
+ self.viz_writer.open()
1112
+ self.viz_writer.write_header(self.script, lib.syminfo, self.viz_journal)
1113
+
1114
+ # If the script is a strategy, we open strategy output files too
1115
+ if is_strat:
1116
+ # Open trade writer if we have one
1117
+ if self.trades_writer:
1118
+ self.trades_writer.open()
1119
+
1120
+ # Clear plot data
1121
+ lib._plot_data.clear()
1122
+ # Reset plot-family metadata, dynamic channels and drawing registries for
1123
+ # this run. Deliberately NOT in ``_reset_lib_vars`` so post-run programmatic
1124
+ # access to ``plot_meta`` / ``drawings()`` keeps working.
1125
+ viz.reset_state()
1126
+
1127
+ # Trade counter
1128
+ trade_num = 0
1129
+
1130
+ # Broker mode watermark: how many entries of the append-only
1131
+ # ``BrokerPosition.new_closed_trades`` have already been flushed to the
1132
+ # trades CSV. Unlike ``SimPosition`` (which rebuilds ``new_closed_trades``
1133
+ # per bar), the broker position never clears the list, so the per-bar
1134
+ # writer must only emit the freshly-appended tail — and the shutdown path
1135
+ # must flush any trades closed after the last bar-close write (e.g. an
1136
+ # intra-bar close right before a graceful shutdown).
1137
+ broker_trades_closed_written = 0
1138
+
1139
+ # Position shortcut — ``SimPosition`` in backtest, ``BrokerPosition``
1140
+ # in broker mode, ``None`` for indicators
1141
+ position = self.script.position
1142
+
1143
+ # --- Security contexts setup ---
1144
+ # Imported library modules can call request.security() too: merge their
1145
+ # contexts (sec ids carry a module hash, so they cannot collide) and
1146
+ # remember every module that needs the security protocol injected
1147
+ sec_modules: list = [self.script_module]
1148
+ for _lib_title, _lib_main in script._registered_libraries:
1149
+ _lib_mod = sys.modules.get(getattr(_lib_main, '__module__', ''))
1150
+ if _lib_mod is not None and _lib_mod is not self.script_module:
1151
+ sec_modules.append(_lib_mod)
1152
+ _merged_contexts: dict[str, dict] = {}
1153
+ for _sec_mod in sec_modules:
1154
+ _mod_contexts: dict[str, dict] | None = getattr(_sec_mod, '__security_contexts__', None)
1155
+ if _mod_contexts:
1156
+ _merged_contexts.update(_mod_contexts)
1157
+ sec_contexts: dict[str, dict] | None = _merged_contexts or None
1158
+ sec_processes: 'dict[str, BaseProcess]' = {}
1159
+ # Abnormally died children, filled by ``watch_security_child`` — lets
1160
+ # the chart's per-bar waits stay UNTIMED (see ``_wait_with_liveness``)
1161
+ sec_failed_children: set[str] = set()
1162
+ sec_resample_dirs: 'list[str]' = [] # per-run temp dirs for HTF feed resampling
1163
+ sec_cleanup_fn: Callable[[], None] | None = None
1164
+ sec_states = None
1165
+ sec_sync_block = None
1166
+ sec_result_blocks = None
1167
+
1168
+ # --- Currency rate provider (default) ---
1169
+ # Always install a provider so ``request.currency_rate()`` works
1170
+ # without a ``request.security()`` context — e.g. when the chart
1171
+ # symbol itself is a currency pair (``lib.close`` is the rate) or
1172
+ # when only legacy file-backed rate sources are supplied via
1173
+ # ``security_data``. Replaced below inside the ``if sec_contexts``
1174
+ # branch with a provider that also reads sec ResultBlocks.
1175
+ from .currency import CurrencyRateProvider
1176
+ from ..lib import request
1177
+ _legacy_file_paths: dict[str, str | Path] = {}
1178
+ for _key, _val in self._security_data.items():
1179
+ if isinstance(_val, (str, Path)):
1180
+ _legacy_file_paths[_key] = _val
1181
+ request._currency_provider = CurrencyRateProvider(
1182
+ security_data=_legacy_file_paths,
1183
+ chart_syminfo=self.syminfo,
1184
+ )
1185
+
1186
+ # Root keys of this run, discarded in the finally block (declared before
1187
+ # the try so the cleanup is safe on any early failure)
1188
+ root_keys: list[str] = []
1189
+
1190
+ try:
1191
+ # Root state vectors of the entry points driven directly by the
1192
+ # runner: a state-carrying main takes the hidden __state__ argument,
1193
+ # a stateless one is called as-is. Keys are qualified per function so
1194
+ # two entry points never collide on one root. Duplicate registrations
1195
+ # of the same function object (a library script run directly registers
1196
+ # its own main as a library too) share one bound entry; a stale
1197
+ # same-name duplicate (module re-imported under the same name) gets a
1198
+ # suffixed key and keeps its own state, like its own module globals
1199
+ # did before the slot-state scheme.
1200
+ main_func = self.script_module.main
1201
+ bound_entries: dict[int, Callable[[], Any]] = {}
1202
+ seen_keys: set[str] = set()
1203
+ for entry_func in [main_func] + [f for _title, f in script._registered_libraries]:
1204
+ if id(entry_func) in bound_entries:
1205
+ continue
1206
+ entry_layout = getattr(entry_func, '__pyne_layout__', None)
1207
+ if entry_layout is None:
1208
+ bound_entries[id(entry_func)] = entry_func
1209
+ continue
1210
+ root_key = f'{entry_func.__module__}.{entry_func.__qualname__}'
1211
+ if root_key in seen_keys:
1212
+ root_key = f'{root_key}#{len(root_keys)}'
1213
+ seen_keys.add(root_key)
1214
+ root_keys.append(root_key)
1215
+ bound_entries[id(entry_func)] = partial(
1216
+ entry_func, instance_state.create_root(root_key, entry_layout))
1217
+ run_main = bound_entries[id(main_func)]
1218
+ lib_mains = [bound_entries[id(f)] for _title, f in script._registered_libraries]
1219
+
1220
+ if sec_contexts:
1221
+ import os
1222
+ max_security = int(os.environ.get('PYNESYS_MAX_SECURITY_CONTEXTS', '64'))
1223
+ if len(sec_contexts) > max_security:
1224
+ raise RuntimeError(
1225
+ f"Script requests too many securities: {len(sec_contexts)} "
1226
+ f"(limit: {max_security}). "
1227
+ f"Set PYNESYS_MAX_SECURITY_CONTEXTS to change the limit."
1228
+ )
1229
+
1230
+ from .security import (
1231
+ setup_security_states, create_chart_protocol,
1232
+ inject_protocol, cleanup_shared_memory, Lookahead,
1233
+ load_htf_bar_opens, load_ltf_first_ms, watch_security_child,
1234
+ )
1235
+ from .security_process import security_process_main
1236
+ from multiprocessing import Process
1237
+
1238
+ # Detect same-context: symbol+TF identical to chart
1239
+ chart_ticker = str(lib.syminfo.ticker)
1240
+ chart_tf = str(lib.syminfo.period)
1241
+ same_context_ids: set[str] = set()
1242
+ for sec_id, ctx in sec_contexts.items():
1243
+ sym = ctx.get('symbol')
1244
+ tf_val = ctx.get('timeframe', chart_tf)
1245
+ if tf_val == '':
1246
+ # An empty string selects the chart's timeframe (Pine semantics)
1247
+ tf_val = chart_tf
1248
+ tf = str(tf_val)
1249
+ if sym is not None and str(sym) == chart_ticker and tf == chart_tf:
1250
+ same_context_ids.add(sec_id)
1251
+
1252
+ # Separate static and deferred contexts. The security transformer
1253
+ # stores None for symbol/timeframe expressions that are not
1254
+ # evaluable at module level (inputs, function parameters), so a
1255
+ # context with either of them None must wait for the runtime
1256
+ # ``__sec_signal__`` values instead of being resolved eagerly.
1257
+ # Same-context ids are excluded from both (no process needed)
1258
+ static_contexts = {}
1259
+ deferred_sec_ids: set[str] = set()
1260
+ for sec_id, ctx in sec_contexts.items():
1261
+ if sec_id in same_context_ids:
1262
+ continue
1263
+ if ctx.get('symbol') is not None and ctx.get('timeframe', '') is not None:
1264
+ static_contexts[sec_id] = ctx
1265
+ else:
1266
+ deferred_sec_ids.add(sec_id)
1267
+
1268
+ # Resolve OHLCV paths for static contexts only
1269
+ sec_ohlcv_paths = (
1270
+ self._resolve_security_data(static_contexts) if static_contexts else {}
1271
+ )
1272
+ # Pre-fetch syminfo for every live-mode PluginSymbol entry
1273
+ # from the chart process, so the chart-side currency-rate
1274
+ # plumbing sees ``(basecurrency, currency)`` before any
1275
+ # subprocess starts, and the subprocess can skip its own
1276
+ # ``update_symbol_info()`` REST call. Pass ``sec_contexts``
1277
+ # so failures on ``ignore_invalid_symbol=True`` contexts
1278
+ # downgrade to None instead of aborting startup.
1279
+ sec_ohlcv_paths = self._prefetch_sec_syminfos(
1280
+ sec_ohlcv_paths, sec_contexts=sec_contexts,
1281
+ )
1282
+
1283
+ # Auto-spawn rate-source contexts for ``currency=X`` requests
1284
+ # that no existing context already covers. Mutates
1285
+ # ``sec_contexts`` / ``static_contexts`` / ``sec_ohlcv_paths``
1286
+ # in place so the rest of the setup treats the new entries
1287
+ # like any other PluginSymbol context.
1288
+ self._autospawn_rate_sources(
1289
+ sec_contexts, static_contexts, sec_ohlcv_paths, chart_tf,
1290
+ )
1291
+
1292
+ # Track ignored sec_ids (ignore_invalid_symbol=True, no data)
1293
+ ignored_sec_ids: set[str] = set()
1294
+ for sec_id, path in sec_ohlcv_paths.items():
1295
+ if path is None:
1296
+ ignored_sec_ids.add(sec_id)
1297
+
1298
+ # No-process IDs: both same-context and ignored. Kept mutable
1299
+ # so the deferred-resolve callback can append late-discovered
1300
+ # ignored symbols (``ignore_invalid_symbol=True`` whose live
1301
+ # syminfo lookup fails) — without that, the chart-side
1302
+ # ``__sec_signal__`` would wait on a process that was never
1303
+ # spawned. ``create_chart_protocol`` captures by reference.
1304
+ no_process_ids: set[str] = set(same_context_ids | ignored_sec_ids)
1305
+
1306
+ sec_states, sec_sync_block, sec_result_blocks = setup_security_states(
1307
+ sec_contexts, chart_tf, self.tz, chart_symbol=chart_ticker,
1308
+ chart_syminfo=self.syminfo, sec_syminfos=self._sec_syminfos,
1309
+ )
1310
+
1311
+ # Tag static (module-level) chart-type contexts so the child
1312
+ # applies the per-bar transform. Deferred contexts (symbol only
1313
+ # known at runtime) are tagged in ``_deferred_resolve`` instead.
1314
+ from ..lib.ticker import _split_chart_type
1315
+ for _sid, _ctx in static_contexts.items():
1316
+ _, _ct = _split_chart_type(str(_ctx.get('symbol', '')))
1317
+ if _ct is not None:
1318
+ sec_states[_sid].chart_type = _ct
1319
+
1320
+ # Currency rate provider — built after the SyncBlock exists so
1321
+ # security-context lookups can read the latest pickled close
1322
+ # from the matching ``ResultBlock``. Only **rate-source**
1323
+ # sec contexts are exposed as FX pairs: arbitrary user
1324
+ # ``request.security()`` expressions are not assumed to
1325
+ # yield close, so reading their ResultBlock as an exchange
1326
+ # rate would silently misuse indicator values as FX rates.
1327
+ legacy_file_paths: dict[str, str | Path] = {}
1328
+ for _key, _val in self._security_data.items():
1329
+ if isinstance(_val, (str, Path)):
1330
+ legacy_file_paths[_key] = _val
1331
+ rate_source_syminfos: dict[str, SymInfo] = {}
1332
+ for _sid, _ps in sec_ohlcv_paths.items():
1333
+ if (isinstance(_ps, PluginSymbol) and _ps.is_rate_source
1334
+ and _ps.syminfo is not None):
1335
+ rate_source_syminfos[_sid] = _ps.syminfo
1336
+ request._currency_provider = CurrencyRateProvider(
1337
+ security_data=legacy_file_paths,
1338
+ chart_syminfo=self.syminfo,
1339
+ sec_syminfos=rate_source_syminfos,
1340
+ sync_block=sec_sync_block,
1341
+ )
1342
+
1343
+ all_sec_ids = list(sec_contexts.keys())
1344
+ script_path_str = str(self._script_path.resolve())
1345
+ sec_result_locks = {
1346
+ sid: state.result_lock for sid, state in sec_states.items()
1347
+ }
1348
+
1349
+ def _spawn_security_process(sid: str, data_source):
1350
+ sec_state = sec_states[sid] # noqa - guaranteed non-None inside if sec_contexts
1351
+ # Chart-type request (``ticker.heikinashi()``): the child
1352
+ # applies the per-bar transform (backtest and live alike), so
1353
+ # there is no live-mode restriction. An LTF (sub-bar) chart
1354
+ # type would need per-intrabar transformation that the
1355
+ # child-side per-period step does not do — reject it clearly.
1356
+ if sec_state.chart_type is not None and sec_state.is_ltf:
1357
+ raise NotImplementedError(
1358
+ f"request.security_lower_tf() with "
1359
+ f"ticker.{sec_state.chart_type}() is not supported.")
1360
+ # D/W/M HTF contexts confirm boundaries by walking the
1361
+ # child's actual bar opens (correct for sparse series).
1362
+ # Backtest only: a file-backed child realizes the real
1363
+ # trading calendar; a live PluginSymbol stream has no static
1364
+ # file to walk.
1365
+ if not isinstance(data_source, PluginSymbol):
1366
+ # Context fed a FINER base feed: pre-resample to the
1367
+ # security timeframe so the child exposes one AGGREGATED
1368
+ # bar per period (TradingView's "resampled from the chart
1369
+ # base data") instead of a single raw sub-bar. No-op for a
1370
+ # feed already at/above the security TF; never for LTF
1371
+ # (needs sub-bars). Same-TF contexts reaching here are
1372
+ # ALWAYS cross-symbol (a same-symbol+same-TF context is a
1373
+ # no-child ``same_context``), so a finer feed for them
1374
+ # must be aggregated to the chart TF too, or the child
1375
+ # would expose a raw sub-bar where TradingView resamples.
1376
+ if not sec_state.is_ltf:
1377
+ data_source = _resample_finer_security_feed(
1378
+ str(data_source), str(sec_state.timeframe),
1379
+ sec_resample_dirs)
1380
+ load_htf_bar_opens(sec_state, str(data_source))
1381
+ load_ltf_first_ms(sec_state, str(data_source))
1382
+ elif sec_state.is_ltf:
1383
+ # Live streaming LTF: no static first bar to load, so the
1384
+ # subprocess pulls intrabars from its own streamer and
1385
+ # ``__sec_signal__`` drives the LTF-window path for every
1386
+ # round (warmup replay and live alike).
1387
+ sec_state.ltf_live_stream = True
1388
+ # Plain-OHLCV fast path: a context whose expression is only
1389
+ # raw price series is served straight from each bar in the
1390
+ # child, skipping the per-bar main() re-run (SecurityTransformer
1391
+ # records the field list in __security_contexts__).
1392
+ _ctx_meta = cast('dict[str, dict]', sec_contexts)[sid]
1393
+ _ohlcv_fields = _ctx_meta.get('ohlcv_fields')
1394
+ _ohlcv_tuple = bool(_ctx_meta.get('ohlcv_tuple'))
1395
+ proc = Process(
1396
+ target=security_process_main,
1397
+ args=(
1398
+ sid,
1399
+ script_path_str,
1400
+ data_source,
1401
+ sec_sync_block.name, # noqa
1402
+ all_sec_ids,
1403
+ sec_state.data_ready,
1404
+ sec_state.advance_event,
1405
+ sec_state.done_event,
1406
+ sec_state.stop_event,
1407
+ sec_state.is_ltf,
1408
+ sec_result_locks,
1409
+ _ohlcv_fields,
1410
+ _ohlcv_tuple,
1411
+ sec_state.chart_type,
1412
+ chart_tf,
1413
+ sec_state.plain_ltf,
1414
+ ),
1415
+ daemon=True,
1416
+ )
1417
+ proc.start()
1418
+ sec_processes[sid] = proc
1419
+ watch_security_child(sid, proc, sec_failed_children,
1420
+ (sec_state.data_ready, sec_state.done_event))
1421
+
1422
+ # Callback for lazy resolution of deferred security contexts
1423
+ def _deferred_resolve(sid: str, symbol: str, timeframe: str | None):
1424
+ if sid not in deferred_sec_ids:
1425
+ return
1426
+ deferred_sec_ids.discard(sid)
1427
+ # Strip any chart-type marker (``ticker.heikinashi()``) so the
1428
+ # same-context / same-symbol decisions run on the base symbol,
1429
+ # and record the chart type so the child transforms per bar.
1430
+ # ``symbol`` keeps the marker for ``_resolve_security_data``
1431
+ # (which needs it to route a same-symbol request to the chart
1432
+ # feed).
1433
+ from ..lib.ticker import _split_chart_type
1434
+ base_symbol, chart_type = _split_chart_type(symbol)
1435
+ # Resolve actual timeframe
1436
+ current_chart_tf = str(lib.syminfo.period)
1437
+ resolved_tf = timeframe if timeframe else current_chart_tf
1438
+ # The context may turn out to be the chart's own symbol and
1439
+ # timeframe: no subprocess and no data file is needed, the
1440
+ # inline same-context write/read path serves it. A chart-type
1441
+ # request (Heikin Ashi) is excluded — it always needs a
1442
+ # subprocess that applies the per-bar transform.
1443
+ if (chart_type is None and chart_ticker is not None
1444
+ and str(base_symbol) == chart_ticker
1445
+ and resolved_tf == current_chart_tf):
1446
+ _state = sec_states[sid] # noqa - guaranteed non-None inside if sec_contexts
1447
+ _state.timeframe = resolved_tf
1448
+ _state.same_timeframe = True
1449
+ _state.resampler = None
1450
+ same_context_ids.add(sid)
1451
+ no_process_ids.add(sid)
1452
+ return
1453
+ # Update SecurityState with correct timeframe info
1454
+ sec_state = sec_states[sid] # noqa - guaranteed non-None inside if sec_contexts
1455
+ sec_state.chart_type = chart_type
1456
+ sec_state.timeframe = resolved_tf
1457
+ same_tf = (resolved_tf == current_chart_tf)
1458
+ sec_state.same_timeframe = same_tf
1459
+ # Plain security resolving to a timeframe FINER than the
1460
+ # chart's: scalar LTF merge (last/first intrabar of the
1461
+ # chart bar) — no resampler, no HTF machinery.
1462
+ plain_ltf = False
1463
+ if not same_tf and not sec_state.is_ltf:
1464
+ from ..lib import timeframe as tf_module
1465
+ sec_seconds = tf_module.in_seconds(resolved_tf)
1466
+ chart_seconds = tf_module.in_seconds(current_chart_tf)
1467
+ plain_ltf = 0 < sec_seconds < chart_seconds
1468
+ sec_state.plain_ltf = plain_ltf
1469
+ sec_state.plain_ltf_span_ms = (
1470
+ sec_seconds * 1000 if plain_ltf else 0) # noqa - bound above when plain_ltf
1471
+ if same_tf or plain_ltf:
1472
+ sec_state.resampler = None
1473
+ elif sec_state.resampler is None:
1474
+ from .resampler import Resampler
1475
+ sec_state.resampler = Resampler.get_resampler(resolved_tf)
1476
+ # Resolve the OHLCV source and prefetch the security's own
1477
+ # syminfo BEFORE the session-anchor decision below, so the
1478
+ # anchor reads ``self._sec_syminfos[sid]`` (the security
1479
+ # symbol's session) instead of falling back to the chart
1480
+ # syminfo. For a cross-symbol HTF in a different exchange
1481
+ # session this is what keeps the HTF grid aligned to the
1482
+ # security's session open.
1483
+ resolve_ctx = {
1484
+ 'symbol': symbol,
1485
+ 'timeframe': resolved_tf,
1486
+ 'ignore_invalid_symbol': cast('dict[str, dict]', sec_contexts)[sid].get(
1487
+ 'ignore_invalid_symbol', False
1488
+ ),
1489
+ }
1490
+ resolved = self._resolve_security_data({sid: resolve_ctx})
1491
+ resolved = self._prefetch_sec_syminfos(
1492
+ resolved, sec_contexts={sid: resolve_ctx},
1493
+ )
1494
+ resolved_path = resolved[sid]
1495
+ sec_ohlcv_paths[sid] = resolved_path
1496
+ # Now that the real symbol/timeframe are known, redo the
1497
+ # intraday session-anchor decision (the placeholder TF at
1498
+ # setup may have been the chart TF, and the syminfo may only
1499
+ # now be resolved).
1500
+ if same_tf or plain_ltf:
1501
+ sec_state.session_starts = None
1502
+ sec_state.session_tz = None
1503
+ else:
1504
+ from .security import resolve_session_anchor
1505
+ si = self._sec_syminfos.get(sid) or self.syminfo
1506
+ sec_state.session_starts, sec_state.session_tz = (
1507
+ resolve_session_anchor(si, resolved_tf, self.tz))
1508
+ if plain_ltf and sec_state.chart_resampler is None:
1509
+ # Single-period civil D/W/M chart: the LTF target needs
1510
+ # the chart bar's civil period end (setup only attaches
1511
+ # this for static ``is_ltf`` contexts).
1512
+ from ..lib import timeframe as tf_module
1513
+ from .resampler import Resampler
1514
+ # noinspection PyProtectedMember
1515
+ chart_mod, chart_mult = tf_module._process_tf(current_chart_tf)
1516
+ if chart_mod in ('D', 'W', 'M') and chart_mult == 1:
1517
+ sec_state.chart_resampler = (
1518
+ Resampler.get_resampler(current_chart_tf))
1519
+ sec_state.chart_dwm_modifier = chart_mod
1520
+ # Now that the real symbol/timeframe are known, decide
1521
+ # whether the live HTF transport applies. ``setup_security_states``
1522
+ # built the aggregator under the assumption ``sym is None``
1523
+ # ⇒ same-symbol; reverse that decision if the resolved symbol
1524
+ # is cross-symbol, or attach one if the timeframe just
1525
+ # promoted from chart-TF to HTF.
1526
+ is_same_symbol = (chart_ticker is None
1527
+ or str(base_symbol) == chart_ticker)
1528
+ needs_aggregator = (not same_tf) and (not plain_ltf) and is_same_symbol
1529
+ if needs_aggregator and sec_state.htf_aggregator is None:
1530
+ from .htf_aggregator import HTFAggregator
1531
+ sec_state.htf_aggregator = HTFAggregator(
1532
+ resolved_tf, self.tz,
1533
+ session_starts=sec_state.session_starts,
1534
+ chart_span_ms=(sec_state.chart_off + 1
1535
+ if sec_state.chart_off else 0))
1536
+ elif not needs_aggregator and sec_state.htf_aggregator is not None:
1537
+ sec_state.htf_aggregator = None
1538
+ elif (needs_aggregator
1539
+ and sec_state.htf_aggregator is not None
1540
+ and sec_state.htf_aggregator.timeframe != resolved_tf):
1541
+ # Timeframe resolved to something different from the
1542
+ # placeholder used at setup — rebuild for the right TF.
1543
+ from .htf_aggregator import HTFAggregator
1544
+ sec_state.htf_aggregator = HTFAggregator(
1545
+ resolved_tf, self.tz,
1546
+ session_starts=sec_state.session_starts,
1547
+ chart_span_ms=(sec_state.chart_off + 1
1548
+ if sec_state.chart_off else 0))
1549
+ # Cross-symbol HTF + lookahead_on: developing bar cannot be
1550
+ # aggregated from chart OHLCV (wrong instrument). Chart-side
1551
+ # read returns ``na`` for every chart bar inside an open HTF
1552
+ # period; the subprocess still advances on closed cross-symbol
1553
+ # HTF bars, so close[1] at the period boundary delivers the
1554
+ # just-closed close.
1555
+ sec_state.na_on_developing = (
1556
+ (not same_tf)
1557
+ and (not plain_ltf)
1558
+ and (not is_same_symbol)
1559
+ and sec_state.lookahead is Lookahead.ON
1560
+ )
1561
+ # OHLCV source and syminfo were resolved above; spawn the
1562
+ # security subprocess (or mark as no-process when the
1563
+ # symbol was downgraded to ``None``).
1564
+ if resolved_path is not None:
1565
+ _spawn_security_process(sid, resolved_path)
1566
+ else:
1567
+ # ``ignore_invalid_symbol=True`` downgraded the live
1568
+ # syminfo lookup to ``None``; mark the sid as
1569
+ # no-process so ``__sec_signal__`` short-circuits
1570
+ # instead of waiting on a child that was never
1571
+ # spawned.
1572
+ no_process_ids.add(sid)
1573
+
1574
+ # Lazy spawn callback for static contexts. The ``sec_processes``
1575
+ # check makes it safe to call after the deferred resolver too —
1576
+ # a deferred context spawns its process inside ``_deferred_resolve``,
1577
+ # and spawning it again would leak a duplicate child.
1578
+ def _lazy_spawn(sid: str):
1579
+ resolved_path = sec_ohlcv_paths.get(sid)
1580
+ if (resolved_path is not None and sid not in no_process_ids
1581
+ and sid not in sec_processes):
1582
+ _spawn_security_process(sid, resolved_path)
1583
+
1584
+ # Eager-spawn auto-rate-source contexts. These hidden
1585
+ # ``__auto_rate_*`` sec_ids carry the FX feed for
1586
+ # ``request.security(..., currency=...)`` requests; no Pine
1587
+ # statement calls ``__sec_signal__`` for them, so the lazy
1588
+ # path never fires. Without an immediate spawn the
1589
+ # subprocess never starts, its :class:`ResultBlock` stays
1590
+ # empty, and ``CurrencyRateProvider`` reads ``NaN`` for
1591
+ # every conversion.
1592
+ for _sid, _ps in sec_ohlcv_paths.items():
1593
+ if (isinstance(_ps, PluginSymbol) and _ps.is_rate_source
1594
+ and _sid not in no_process_ids):
1595
+ _spawn_security_process(_sid, _ps)
1596
+
1597
+ # Build currency conversion map from security contexts.
1598
+ # Live-mode PluginSymbol sources expose syminfo via the
1599
+ # chart-side prefetch (``self._sec_syminfos``); file-mode
1600
+ # sources still load it from the sibling ``.toml``.
1601
+ currency_conversions: dict[str, tuple[str, str]] = {}
1602
+ for sec_id, ctx in sec_contexts.items():
1603
+ target_cur = ctx.get('currency')
1604
+ if target_cur is None:
1605
+ continue
1606
+ target_cur_str = str(target_cur)
1607
+ if not target_cur_str or target_cur_str.lower() in ('', 'na', 'nan'):
1608
+ continue
1609
+ sec_si = self._sec_syminfos.get(sec_id)
1610
+ if sec_si is None:
1611
+ ohlcv_path = sec_ohlcv_paths.get(sec_id)
1612
+ if isinstance(ohlcv_path, str):
1613
+ sec_toml = Path(ohlcv_path).with_suffix('.toml')
1614
+ if sec_toml.exists():
1615
+ sec_si = SymInfo.load_toml(sec_toml)
1616
+ if sec_si is not None and sec_si.currency:
1617
+ currency_conversions[sec_id] = (sec_si.currency, target_cur_str)
1618
+
1619
+ # Passed BY REFERENCE (like ``no_process_ids``): the deferred-resolve
1620
+ # callback can discover late that a context is the chart's own
1621
+ # symbol+timeframe and append it, and every consumer — the protocol
1622
+ # closures and the modules' ``__same_context__`` — must see that
1623
+ same_ctx_ref = same_context_ids
1624
+ # Collect hidden ``__auto_rate_*`` sec_ids so the chart
1625
+ # loop can tick their subprocesses each bar — no Pine call
1626
+ # signals them, and without per-bar advance their
1627
+ # ResultBlock stays empty and ``CurrencyRateProvider``
1628
+ # returns NaN for every conversion.
1629
+ auto_rate_sec_ids = frozenset(
1630
+ sid for sid, ps in sec_ohlcv_paths.items()
1631
+ if isinstance(ps, PluginSymbol) and ps.is_rate_source
1632
+ and sid not in no_process_ids
1633
+ )
1634
+ (signal_fn, write_fn, read_fn, wait_fn,
1635
+ sec_cleanup_fn, signal_rate_sources_fn) = create_chart_protocol(
1636
+ sec_states, sec_sync_block,
1637
+ deferred_resolve_fn=_deferred_resolve if deferred_sec_ids else None,
1638
+ lazy_spawn_fn=_lazy_spawn if static_contexts else None,
1639
+ same_context_ids=same_ctx_ref,
1640
+ no_process_ids=no_process_ids,
1641
+ # Unconditional: ``same_context_ids`` can gain members AFTER setup
1642
+ # (a deferred context resolving to the chart's own symbol+TF), and
1643
+ # ``__sec_write__`` no-ops on ``result_blocks=None`` — gating on the
1644
+ # set being non-empty here would leave such a context's
1645
+ # ``data_ready`` forever unset and deadlock its ``__sec_read__``.
1646
+ result_blocks=sec_result_blocks,
1647
+ currency_conversions=currency_conversions or None,
1648
+ sec_processes=sec_processes,
1649
+ auto_rate_sec_ids=auto_rate_sec_ids,
1650
+ failed_children=sec_failed_children,
1651
+ )
1652
+ for _sec_mod in sec_modules:
1653
+ inject_protocol(_sec_mod, signal_fn, write_fn, read_fn, wait_fn,
1654
+ same_context=same_ctx_ref)
1655
+ self._signal_rate_sources_fn = signal_rate_sources_fn
1656
+
1657
+ # Initialize calc_on_order_fills snapshot (for COOF or live mode).
1658
+ # Pine TV semantics: `calc_on_order_fills` is silently disabled when
1659
+ # `process_orders_on_close=True` (TV reverts to a single script calculation
1660
+ # per bar in that combo), so the snapshot stays unused in that case.
1661
+ var_snapshot: instance_state.RootVarSnapshot | None = None
1662
+ is_live = lib._is_live
1663
+ # Indicators always run on every tick; strategies only if calc_on_every_tick
1664
+ run_on_every_tick = not is_strat or self.script.calc_on_every_tick
1665
+ if (is_strat and self.script.calc_on_order_fills
1666
+ and not self.script.process_orders_on_close):
1667
+ var_snapshot = instance_state.RootVarSnapshot(root_keys)
1668
+ elif is_live and run_on_every_tick:
1669
+ var_snapshot = instance_state.RootVarSnapshot(root_keys)
1670
+
1671
+ # --timeframe mode: magnifier_iter provides sub-TF data
1672
+ if self._magnifier_iter is not None:
1673
+ if is_strat and self.script.use_bar_magnifier:
1674
+ # Bar magnifier: accurate order fills at sub-bar resolution
1675
+ yield from self._run_iter_magnified(
1676
+ lib, barstate, position, run_main, lib_mains, var_snapshot,
1677
+ is_strat=is_strat, on_progress=on_progress, string=string,
1678
+ )
1679
+ return
1680
+ else:
1681
+ # On-the-fly aggregation: aggregate sub-TF to chart TF
1682
+ from .bar_magnifier import BarMagnifier
1683
+ chart_tf = str(lib.syminfo.period)
1684
+ magnifier = BarMagnifier(self._magnifier_iter, chart_tf, tz=self.tz,
1685
+ session_starts=self.syminfo.session_starts,
1686
+ opening_hours=self.syminfo.opening_hours,
1687
+ sym_type=self.syminfo.type,
1688
+ source_tf=self._magnifier_source_tf)
1689
+ self.ohlcv_iter = (w.aggregated for w in magnifier)
1690
+
1691
+ # --- Helper closures for DRY ---
1692
+ signal_rate_sources_fn = self._signal_rate_sources_fn
1693
+
1694
+ # noinspection PyProtectedMember
1695
+ def _run_libs_and_main():
1696
+ # Broker mode only: open a fresh order-evaluation scope before
1697
+ # any strategy.close() runs, so two same-bar closes net into one
1698
+ # order while a calc_on_every_tick re-issue replaces rather than
1699
+ # doubles the pending close (see BrokerPosition.begin_evaluation).
1700
+ if self._order_sync_engine is not None:
1701
+ position.begin_evaluation()
1702
+ # Advance hidden ``__auto_rate_*`` subprocesses before
1703
+ # libraries/main run so any ``request.currency_rate`` /
1704
+ # ``currency=`` conversion looks up a freshly-written
1705
+ # close from the rate-source ResultBlock instead of NaN.
1706
+ if signal_rate_sources_fn is not None:
1707
+ # noinspection PyCallingNonCallable
1708
+ signal_rate_sources_fn()
1709
+ lib._lib_semaphore = True
1710
+ for run_lib_main in lib_mains:
1711
+ run_lib_main()
1712
+ lib._lib_semaphore = False
1713
+ r = run_main()
1714
+ if r is not None:
1715
+ assert isinstance(r, dict), "The 'main' function must return a dictionary!"
1716
+ lib._plot_data.update(r)
1717
+
1718
+ # noinspection PyProtectedMember
1719
+ def _write_bar_output(bar_candle):
1720
+ nonlocal trade_num, broker_trades_closed_written
1721
+ if self.plot_writer and lib._plot_data:
1722
+ ef = {} if bar_candle.extra_fields is None else dict(bar_candle.extra_fields)
1723
+ ef.update(lib._plot_data)
1724
+ self.plot_writer.write_ohlcv(bar_candle._replace(extra_fields=ef))
1725
+
1726
+ self._write_viz_bar(bar_candle)
1727
+
1728
+ if is_strat and self.trades_writer and position:
1729
+ # ``SimPosition`` rebuilds ``new_closed_trades`` every bar, so
1730
+ # the whole list is this bar's closes. ``BrokerPosition`` never
1731
+ # clears it (it is the session-wide closed-trade log), so slice
1732
+ # off only the tail appended since the last write to avoid
1733
+ # re-emitting every prior trade on each subsequent bar.
1734
+ if self._broker_mode:
1735
+ new_trades = position.new_closed_trades[broker_trades_closed_written:]
1736
+ broker_trades_closed_written = len(position.new_closed_trades)
1737
+ else:
1738
+ new_trades = position.new_closed_trades
1739
+ for t in new_trades:
1740
+ trade_num += 1
1741
+ self.trades_writer.write(
1742
+ trade_num, t.entry_bar_index,
1743
+ "Entry long" if t.size > 0 else "Entry short",
1744
+ t.entry_comment if t.entry_comment else t.entry_id,
1745
+ string.format_time(t.entry_time), # type: ignore
1746
+ t.entry_price, abs(t.size), t.profit,
1747
+ f"{t.profit_percent:.2f}", t.cum_profit,
1748
+ f"{t.cum_profit_percent:.2f}", t.max_runup,
1749
+ f"{t.max_runup_percent:.2f}", t.max_drawdown,
1750
+ f"{t.max_drawdown_percent:.2f}",
1751
+ )
1752
+ self.trades_writer.write(
1753
+ trade_num, t.exit_bar_index,
1754
+ "Exit long" if t.size > 0 else "Exit short",
1755
+ t.exit_comment if t.exit_comment else t.exit_id,
1756
+ string.format_time(t.exit_time), # type: ignore
1757
+ t.exit_price, abs(t.size), t.profit,
1758
+ f"{t.profit_percent:.2f}", t.cum_profit,
1759
+ f"{t.cum_profit_percent:.2f}", t.max_runup,
1760
+ f"{t.max_runup_percent:.2f}", t.max_drawdown,
1761
+ f"{t.max_drawdown_percent:.2f}",
1762
+ )
1763
+
1764
+ # noinspection PyProtectedMember
1765
+ def _coof_loop():
1766
+ """COOF re-execution loop: process orders, re-execute on fills."""
1767
+ # Broker mode: no synchronous fill-driven re-execution — exchange
1768
+ # fills arrive asynchronously and are routed on the next sync.
1769
+ if self._broker_mode:
1770
+ self._process_orders(position)
1771
+ return
1772
+ sim = cast('SimPosition', position)
1773
+ old_fills = sim._fill_counter
1774
+ sim.process_orders()
1775
+ new_fills = sim._fill_counter
1776
+ while new_fills > old_fills:
1777
+ if var_snapshot.has_vars: # type: ignore
1778
+ var_snapshot.restore() # type: ignore
1779
+ instance_state.reset()
1780
+ _run_libs_and_main()
1781
+ old_fills = new_fills
1782
+ sim.process_orders()
1783
+ new_fills = sim._fill_counter
1784
+
1785
+ # noinspection PyProtectedMember
1786
+ def _coof_magnified_loop(sub_bars_list, aggregated_candle):
1787
+ """COOF re-execution loop with magnified order processing."""
1788
+ if self._broker_mode:
1789
+ self._process_orders(position)
1790
+ return
1791
+ sim = cast('SimPosition', position)
1792
+ old_fills = sim._fill_counter
1793
+ sim.process_orders_magnified(sub_bars_list, aggregated_candle)
1794
+ new_fills = sim._fill_counter
1795
+ while new_fills > old_fills:
1796
+ if var_snapshot.has_vars: # type: ignore
1797
+ var_snapshot.restore() # type: ignore
1798
+ instance_state.reset()
1799
+ _run_libs_and_main()
1800
+ old_fills = new_fills
1801
+ sim.process_orders_magnified(sub_bars_list, aggregated_candle)
1802
+ new_fills = sim._fill_counter
1803
+
1804
+ # --- Peek-ahead pattern: historical bars ---
1805
+ # LIVE_TRANSITION doubles as end-of-data sentinel → next() always returns OHLCV
1806
+ ohlcv_iterator = iter(self.ohlcv_iter)
1807
+ next_item = next(ohlcv_iterator, LIVE_TRANSITION)
1808
+ first_live_update: OHLCV | None = None
1809
+ # Tracks the last warmup-bar timestamp so the live loop can tell
1810
+ # whether the first live update is a new bar or an intra-bar
1811
+ # tick of the warmup's last bar (e.g. the still-open bar that
1812
+ # ``download_ohlcv`` brought in as historical).
1813
+ last_warmup_timestamp: int | None = None
1814
+ warmup_bars_processed = 0
1815
+
1816
+ # calc_bars_count: Pine restricts calculation to the last N chart
1817
+ # bars. Earlier bars are not calculated at all -- series start fresh
1818
+ # (na warmup) at the first calculated bar, while bar_index keeps its
1819
+ # absolute value and last_bar_index is unchanged. 0 (or a value that
1820
+ # covers the whole history) calculates every bar.
1821
+ calc_bars_count = getattr(self.script, 'calc_bars_count', 0) or 0
1822
+ calc_start = self.last_bar_index + 1 - calc_bars_count if calc_bars_count > 0 else 0
1823
+
1824
+ if is_live and self._broker_plugin is not None:
1825
+ broker_info("warmup phase started — replaying historical bars")
1826
+
1827
+ while next_item is not LIVE_TRANSITION:
1828
+ candle = next_item
1829
+ next_item = next(ohlcv_iterator, LIVE_TRANSITION)
1830
+
1831
+ # Pre-increment: bar_index becomes the index of the bar we
1832
+ # are about to process (first bar -> 0).
1833
+ self.bar_index += 1
1834
+ # Skip bars before the calc_bars_count window: advance bar_index
1835
+ # to keep it absolute, but feed no series, run no main, process
1836
+ # no orders and emit no output for uncalculated history.
1837
+ if self.bar_index < calc_start:
1838
+ continue
1839
+ last_warmup_timestamp = candle.timestamp
1840
+ warmup_bars_processed += 1
1841
+
1842
+ # Update syminfo lib properties if needed
1843
+ if self.update_syminfo_every_run:
1844
+ _set_lib_syminfo_properties(self.syminfo)
1845
+ self.tz = _parse_timezone(lib.syminfo.timezone)
1846
+
1847
+ # Last bar detection
1848
+ if is_live:
1849
+ barstate.islast = False
1850
+ barstate.islastconfirmedhistory = (next_item is LIVE_TRANSITION)
1851
+ else:
1852
+ barstate.islast = (next_item is LIVE_TRANSITION)
1853
+
1854
+ # Update lib properties
1855
+ _set_lib_properties(
1856
+ candle, self.bar_index, self.tz, lib, self._round_decimals,
1857
+ self.last_bar_index, self.last_bar_time,
1858
+ )
1859
+
1860
+ # Store first price for buy & hold calculation
1861
+ if self.first_price is None:
1862
+ self.first_price = lib.close # type: ignore
1863
+ self.last_price = lib.close # type: ignore
1864
+
1865
+ # calc_on_order_fills path: snapshot, process, re-execute on fills
1866
+ if var_snapshot and position and not lib._strategy_suppressed:
1867
+ if var_snapshot.has_vars:
1868
+ var_snapshot.save()
1869
+ _coof_loop()
1870
+ if var_snapshot.has_vars:
1871
+ var_snapshot.restore()
1872
+ elif is_strat and position and not lib._strategy_suppressed:
1873
+ self._process_orders(position)
1874
+
1875
+ # Execute libraries + script
1876
+ _run_libs_and_main()
1877
+
1878
+ # Fill strategy.close(_all)(immediately=true) orders enqueued during
1879
+ # the body, at this bar's close — after the body so position series
1880
+ # stayed constant for the rest of the bar. Simulator-only.
1881
+ if (is_strat and position and not self._broker_mode
1882
+ and not lib._strategy_suppressed):
1883
+ cast('SimPosition', position).settle_immediate_closes()
1884
+
1885
+ # Pine `process_orders_on_close=true` — extra fill attempt at the bar
1886
+ # close for current-bar orders, before the next bar's open arrives.
1887
+ # No COOF re-run here: Pine disables `calc_on_order_fills` when this
1888
+ # flag is set (var_snapshot is None whenever both are true).
1889
+ # Simulator-only; in broker mode the exchange owns fill timing.
1890
+ if (is_strat and position and not self._broker_mode
1891
+ and not lib._strategy_suppressed
1892
+ and self.script.process_orders_on_close):
1893
+ cast('SimPosition', position).process_orders_at_close()
1894
+
1895
+ # Process deferred margin calls
1896
+ if is_strat and position and not lib._strategy_suppressed:
1897
+ self._process_deferred_margin_call(position)
1898
+
1899
+ # Write output
1900
+ _write_bar_output(candle)
1901
+
1902
+ # Yield
1903
+ if not is_strat:
1904
+ yield candle, lib._plot_data
1905
+ elif position:
1906
+ yield candle, lib._plot_data, position.new_closed_trades
1907
+
1908
+ lib._plot_data.clear()
1909
+ lib._viz_dyn.clear()
1910
+ lib._viz_seq.clear()
1911
+
1912
+ if is_strat and position:
1913
+ current_equity = float(position.equity) if position.equity \
1914
+ else self.script.initial_capital
1915
+ self.equity_curve.append(current_equity)
1916
+
1917
+ if on_progress and lib._datetime is not None:
1918
+ on_progress(lib._datetime.replace(tzinfo=None))
1919
+
1920
+ barstate.isfirst = False
1921
+
1922
+ if is_live and self._broker_plugin is not None:
1923
+ broker_info(
1924
+ "warmup phase complete — %d bar(s) processed",
1925
+ warmup_bars_processed,
1926
+ )
1927
+
1928
+ # --- Live mode: transition and intra-bar loop ---
1929
+ # Flip the historical→live flags and emit the transition log
1930
+ # **before** blocking on the first WS bar. Otherwise the log
1931
+ # appears to fire only when the first live update arrives,
1932
+ # which can be a full period later (or never if the WS push
1933
+ # for the boundary bar is dedup-eaten upstream) — making the
1934
+ # transition look gated on data instead of on the warmup
1935
+ # boundary it actually represents.
1936
+ if next_item is LIVE_TRANSITION and is_live:
1937
+ barstate.ishistory = False
1938
+ barstate.isrealtime = True
1939
+ barstate.islastconfirmedhistory = False
1940
+ lib._strategy_suppressed = False
1941
+
1942
+ # Promote ``request.security()`` contexts into live mode so
1943
+ # ``lookahead_on`` switches to the developing-bar transport
1944
+ # (see ``security.SecurityState.is_live``).
1945
+ if sec_states is not None:
1946
+ for _sec_state in sec_states.values():
1947
+ _sec_state.is_live = True
1948
+
1949
+ if self._broker_mode:
1950
+ # ``bar_index`` and ``lib._time`` are still pointing at
1951
+ # the last warmup bar (e.g. 499) — this log line marks
1952
+ # the transition AT that boundary; the next live bar
1953
+ # arrival will pre-increment to 500.
1954
+ broker_info("live trading active")
1955
+
1956
+ # Flush output at transition point.
1957
+ if self.plot_writer:
1958
+ self.plot_writer.flush()
1959
+ if self.trades_writer:
1960
+ self.trades_writer.flush()
1961
+
1962
+ first_live_update = next(ohlcv_iterator, None)
1963
+
1964
+ if first_live_update is not None:
1965
+ import itertools
1966
+
1967
+ # Seed with the last warmup bar's timestamp so that an
1968
+ # incoming live update with the same timestamp (common when
1969
+ # ``download_ohlcv`` returned the still-open current bar)
1970
+ # is recognised as a continuation of the last warmup bar
1971
+ # instead of a fresh one.
1972
+ last_bar_timestamp: int | None = last_warmup_timestamp
1973
+ sub_bars: list[OHLCV] = []
1974
+
1975
+ live_stream = itertools.chain([first_live_update], ohlcv_iterator)
1976
+ for bar_update in live_stream:
1977
+ # An async halt latched on the broker event-loop thread
1978
+ # (e.g. ``UnexpectedCancelError`` from a polling plugin)
1979
+ # must surface NOW — before ``[OHLCV]`` is logged or any
1980
+ # state advances. Without this, a halt set mid-bar would
1981
+ # only fire at the next bar close (via
1982
+ # ``apply_async_events``), spilling a bogus OHLCV log line
1983
+ # for a bar the bot is no longer trading.
1984
+ if self._order_sync_engine is not None:
1985
+ cast('OrderSyncEngine', self._order_sync_engine).raise_if_halted()
1986
+
1987
+ candle = bar_update
1988
+ is_new_bar = (candle.timestamp != last_bar_timestamp)
1989
+
1990
+ if is_new_bar:
1991
+ # Pre-increment on bar open; intra-bar ticks for the
1992
+ # same bar reuse the index already assigned here.
1993
+ self.bar_index += 1
1994
+
1995
+ barstate.islast = True
1996
+ barstate.isconfirmed = bar_update.is_closed
1997
+ barstate.isnew = is_new_bar
1998
+
1999
+ _set_lib_properties(candle, self.bar_index, self.tz, lib, self._round_decimals)
2000
+
2001
+ if self.first_price is None:
2002
+ self.first_price = lib.close # type: ignore
2003
+ self.last_price = lib.close # type: ignore
2004
+
2005
+ # Fire per-update tick hook (bid/ask spinner, other UI).
2006
+ if on_tick is not None:
2007
+ on_tick(candle)
2008
+
2009
+ if is_new_bar and not bar_update.is_closed:
2010
+ # ── Bar open (first intra-bar tick) ──
2011
+ sub_bars = [candle]
2012
+ if run_on_every_tick:
2013
+ if var_snapshot and var_snapshot.has_vars:
2014
+ var_snapshot.save()
2015
+ # Broker sync runs before the script so orders queued by the
2016
+ # previous tick dispatch now, and async fills from watch_orders
2017
+ # become visible to this script run via record_fill.
2018
+ if is_strat and position and self._broker_mode \
2019
+ and not lib._strategy_suppressed:
2020
+ self._process_orders(position)
2021
+ _run_libs_and_main()
2022
+ last_bar_timestamp = candle.timestamp
2023
+
2024
+ elif not bar_update.is_closed:
2025
+ # ── Subsequent intra-bar tick ──
2026
+ sub_bars.append(candle)
2027
+ if run_on_every_tick:
2028
+ if var_snapshot and var_snapshot.has_vars:
2029
+ var_snapshot.restore()
2030
+ instance_state.reset()
2031
+ if is_strat and position and self._broker_mode \
2032
+ and not lib._strategy_suppressed:
2033
+ self._process_orders(position)
2034
+ _run_libs_and_main()
2035
+
2036
+ elif bar_update.is_closed:
2037
+ # ── Bar close ──
2038
+ if is_new_bar:
2039
+ sub_bars = []
2040
+ if var_snapshot and var_snapshot.has_vars:
2041
+ var_snapshot.save()
2042
+ else:
2043
+ sub_bars.append(candle)
2044
+ if run_on_every_tick:
2045
+ if var_snapshot and var_snapshot.has_vars:
2046
+ var_snapshot.restore()
2047
+ instance_state.reset()
2048
+
2049
+ # Strategy not running on ticks: bar close is first execution
2050
+ if not run_on_every_tick:
2051
+ barstate.isnew = True
2052
+
2053
+ # Per-bar OHLCV log (live mode; opt-out via --no-log-ohlcv).
2054
+ # Logged at bar close *before* strategy processing so
2055
+ # the on-screen log order — `[OHLCV] ... → [BROKER]
2056
+ # dispatching ENTRY ... → [BROKER] fill ...` — matches
2057
+ # the actual event order. Logging after the strategy
2058
+ # ran would make orders appear before the bar that
2059
+ # caused them.
2060
+ if self._log_ohlcv:
2061
+ extra = candle.extra_fields or {}
2062
+ spread = extra.get('spread')
2063
+ d = self._price_decimals
2064
+ if spread is not None:
2065
+ ohlcv_info(
2066
+ "O=%.*f H=%.*f L=%.*f C=%.*f "
2067
+ "spread=%.*f V=%.0f",
2068
+ d, candle.open, d, candle.high,
2069
+ d, candle.low, d, candle.close,
2070
+ d, spread,
2071
+ candle.volume,
2072
+ )
2073
+ else:
2074
+ ohlcv_info(
2075
+ "O=%.*f H=%.*f L=%.*f C=%.*f V=%.0f",
2076
+ d, candle.open, d, candle.high,
2077
+ d, candle.low, d, candle.close,
2078
+ candle.volume,
2079
+ )
2080
+
2081
+ if self._broker_mode:
2082
+ # Broker mode: run the script FIRST (this bar's
2083
+ # close queues new orders) and THEN sync the
2084
+ # exchange so dispatch happens *on the same bar*.
2085
+ # Calling sync first would dispatch the previous
2086
+ # close's queue here, adding one full bar of
2087
+ # stale latency to every entry/exit. TV live
2088
+ # semantics: a market order placed at bar close
2089
+ # fills near the next bar's open price (sub-second
2090
+ # in practice). Pine sub-bar magnification and
2091
+ # synchronous COOF re-execution don't apply —
2092
+ # the exchange is the source of truth.
2093
+ #
2094
+ # Async fills (from ``watch_orders``) are
2095
+ # drained *before* the script so the new bar's
2096
+ # script sees the updated ``position.size``
2097
+ # immediately rather than one bar later.
2098
+ if self._order_sync_engine is not None:
2099
+ try:
2100
+ cast('OrderSyncEngine', self._order_sync_engine) \
2101
+ .apply_async_events()
2102
+ except ExchangeConnectionError as e:
2103
+ # A recoverable broker loss surfaced while
2104
+ # draining async fills (e.g. a deferred entry
2105
+ # re-dispatch after a failed re-auth). Skip the
2106
+ # drain this bar; the next bar retries. A halt
2107
+ # is not caught here and still stops the bot.
2108
+ broker_warning(
2109
+ "async event apply skipped after "
2110
+ "connection error: %s — retrying next bar",
2111
+ e,
2112
+ )
2113
+ # Risk management hooks (broker-side parity with
2114
+ # the sim's ``process_orders`` rollover/halt block):
2115
+ # mark-to-market the open P&L so the equity-based
2116
+ # drawdown / intraday-loss predicates use a fresh
2117
+ # price; roll over the day counters before the
2118
+ # script runs (so a day-rollover halt prevents a
2119
+ # new entry from queueing); and enforce post-bar
2120
+ # rules before the sync so the queued risk-close
2121
+ # ships in the same dispatch cycle.
2122
+ if is_strat and position:
2123
+ bpos = cast('BrokerPosition', position)
2124
+ bpos.update_unrealized_pnl(float(lib.close))
2125
+ # noinspection PyProtectedMember
2126
+ bpos._handle_bar_open_risk()
2127
+ lib._plot_data.clear()
2128
+ lib._viz_dyn.clear()
2129
+ lib._viz_seq.clear()
2130
+ # Restart settle: this branch runs the script BEFORE
2131
+ # sync (so a bar-close order dispatches same-bar), but
2132
+ # on the first bar after a restart the Pine order book
2133
+ # is still empty — a first-bar strategy.cancel/exit
2134
+ # would no-op against empty exit_orders and then be
2135
+ # overwritten by the reconstruction inside the
2136
+ # post-script sync. Reconstruct here, before the
2137
+ # script, so its mutation takes effect. Idempotent:
2138
+ # returns immediately once the one-time reconstruct
2139
+ # has latched, so steady-state bars pay nothing.
2140
+ if is_strat and position \
2141
+ and self._order_sync_engine is not None:
2142
+ cast('OrderSyncEngine', self._order_sync_engine) \
2143
+ .settle_restart_state(int(lib.last_bar_time))
2144
+ _run_libs_and_main()
2145
+ if is_strat and position:
2146
+ # noinspection PyProtectedMember
2147
+ cast('BrokerPosition', position)._enforce_post_bar_risk()
2148
+ self._process_orders(position)
2149
+ else:
2150
+ # Backtest: simulator first (fills the previous
2151
+ # close's queue at this bar's open price), then
2152
+ # script executes at this bar's close.
2153
+ if is_strat and position:
2154
+ if sub_bars:
2155
+ if var_snapshot and var_snapshot.has_vars:
2156
+ _coof_magnified_loop(sub_bars, candle)
2157
+ var_snapshot.restore()
2158
+ else:
2159
+ self._process_orders_magnified(position, sub_bars, candle)
2160
+ else:
2161
+ if var_snapshot and var_snapshot.has_vars:
2162
+ _coof_loop()
2163
+ var_snapshot.restore()
2164
+ else:
2165
+ self._process_orders(position)
2166
+
2167
+ # Paper-trading narration: the simulator just
2168
+ # filled the previous bar's queued orders — log
2169
+ # them so live sim mode has the same per-fill
2170
+ # visibility as broker mode's ``[BROKER]`` lines.
2171
+ if is_strat and position:
2172
+ self._log_sim_fills(position)
2173
+
2174
+ lib._plot_data.clear()
2175
+ lib._viz_dyn.clear()
2176
+ lib._viz_seq.clear()
2177
+ _run_libs_and_main()
2178
+
2179
+ # Fill immediate closes enqueued during the body, at
2180
+ # this bar's close — after the body (backtest/paper).
2181
+ if is_strat and position and not lib._strategy_suppressed:
2182
+ cast('SimPosition', position).settle_immediate_closes()
2183
+
2184
+ if is_strat and position:
2185
+ self._process_deferred_margin_call(position)
2186
+
2187
+ # Commit state for next bar
2188
+ if var_snapshot and var_snapshot.has_vars:
2189
+ var_snapshot.save()
2190
+
2191
+ # Output (only on closed bars)
2192
+ _write_bar_output(candle)
2193
+
2194
+ if not is_strat:
2195
+ yield candle, lib._plot_data
2196
+ elif position:
2197
+ yield candle, lib._plot_data, position.new_closed_trades
2198
+
2199
+ lib._plot_data.clear()
2200
+ lib._viz_dyn.clear()
2201
+ lib._viz_seq.clear()
2202
+
2203
+ if is_strat and position:
2204
+ current_equity = float(position.equity) if position.equity \
2205
+ else self.script.initial_capital
2206
+ self.equity_curve.append(current_equity)
2207
+
2208
+ last_bar_timestamp = candle.timestamp
2209
+ barstate.isfirst = False
2210
+
2211
+ # Live strategy stats: rewrite stats file after each bar
2212
+ if is_strat and self.strat_writer and position:
2213
+ self._write_live_strategy_stats(position)
2214
+
2215
+ if on_progress and lib._datetime is not None:
2216
+ on_progress(lib._datetime.replace(tzinfo=None))
2217
+
2218
+ elif on_progress:
2219
+ on_progress(datetime.max)
2220
+
2221
+ except GeneratorExit:
2222
+ pass
2223
+
2224
+ finally: # Python reference counter will close this even if the iterator is not exhausted
2225
+ if is_strat and position:
2226
+ # Broker mode: flush trades that closed after the last bar-close
2227
+ # write (e.g. an intra-bar close settled right before a graceful
2228
+ # shutdown). ``_write_bar_output`` runs only on closed bars, so
2229
+ # without this the closing rows of such a trade would be lost even
2230
+ # though the strategy statistics already count it as closed.
2231
+ if self.trades_writer and self._broker_mode:
2232
+ pending_closed = position.new_closed_trades[broker_trades_closed_written:]
2233
+ broker_trades_closed_written = len(position.new_closed_trades)
2234
+ for t in pending_closed:
2235
+ trade_num += 1
2236
+ self.trades_writer.write(
2237
+ trade_num, t.entry_bar_index,
2238
+ "Entry long" if t.size > 0 else "Entry short",
2239
+ t.entry_comment if t.entry_comment else t.entry_id,
2240
+ string.format_time(t.entry_time), # type: ignore
2241
+ t.entry_price, abs(t.size), t.profit,
2242
+ f"{t.profit_percent:.2f}", t.cum_profit,
2243
+ f"{t.cum_profit_percent:.2f}", t.max_runup,
2244
+ f"{t.max_runup_percent:.2f}", t.max_drawdown,
2245
+ f"{t.max_drawdown_percent:.2f}",
2246
+ )
2247
+ self.trades_writer.write(
2248
+ trade_num, t.exit_bar_index,
2249
+ "Exit long" if t.size > 0 else "Exit short",
2250
+ t.exit_comment if t.exit_comment else t.exit_id,
2251
+ string.format_time(t.exit_time), # type: ignore
2252
+ t.exit_price, abs(t.size), t.profit,
2253
+ f"{t.profit_percent:.2f}", t.cum_profit,
2254
+ f"{t.cum_profit_percent:.2f}", t.max_runup,
2255
+ f"{t.max_runup_percent:.2f}", t.max_drawdown,
2256
+ f"{t.max_drawdown_percent:.2f}",
2257
+ )
2258
+
2259
+ # Export remaining open trades before closing
2260
+ if self.trades_writer and position.open_trades:
2261
+ for trade in position.open_trades:
2262
+ trade_num += 1 # Continue numbering from closed trades
2263
+ # Export the entry part
2264
+ self.trades_writer.write(
2265
+ trade_num,
2266
+ trade.entry_bar_index,
2267
+ "Entry long" if trade.size > 0 else "Entry short",
2268
+ trade.entry_id,
2269
+ string.format_time(trade.entry_time), # type: ignore
2270
+ trade.entry_price,
2271
+ abs(trade.size),
2272
+ 0.0, # No profit yet for open trades
2273
+ "0.00", # No profit percent yet
2274
+ 0.0, # No cumulative profit change
2275
+ "0.00", # No cumulative profit percent change
2276
+ 0.0, # No max runup yet
2277
+ "0.00", # No max runup percent yet
2278
+ 0.0, # No max drawdown yet
2279
+ "0.00", # No max drawdown percent yet
2280
+ )
2281
+
2282
+ # Export the exit part with "Open" signal (TradingView compatibility)
2283
+ # This simulates automatic closing at the end of backtest
2284
+ # Use the last price from the iteration
2285
+ exit_price = self.last_price
2286
+
2287
+ if exit_price is not None:
2288
+ # Calculate profit/loss using the same formula as Position._fill_order
2289
+ # For closing, size is negative of the position.
2290
+ # `* syminfo.pointvalue` converts price-delta to account-currency
2291
+ # so the synthetic "Open" exit reports USD consistently with closed
2292
+ # trades on futures (pv != 1). For pv = 1 this is a no-op.
2293
+ pv = self.syminfo.pointvalue
2294
+ closing_size = -trade.size
2295
+ pnl = -closing_size * (exit_price - trade.entry_price) * pv
2296
+ entry_value = abs(trade.size) * trade.entry_price * pv
2297
+ pnl_percent = (pnl / entry_value) * 100 if entry_value != 0 else 0
2298
+
2299
+ self.trades_writer.write(
2300
+ trade_num,
2301
+ self.bar_index, # Last bar index processed
2302
+ "Exit long" if trade.size > 0 else "Exit short",
2303
+ "Open", # TradingView uses "Open" signal for automatic closes
2304
+ string.format_time(lib._time), # type: ignore
2305
+ exit_price,
2306
+ abs(trade.size),
2307
+ pnl,
2308
+ f"{pnl_percent:.2f}",
2309
+ pnl, # Same as profit for last trade
2310
+ f"{pnl_percent:.2f}",
2311
+ max(0.0, pnl), # Runup
2312
+ f"{max(0, pnl_percent):.2f}",
2313
+ max(0.0, -pnl), # Drawdown
2314
+ f"{max(0, -pnl_percent):.2f}",
2315
+ )
2316
+
2317
+ # Calculate strategy statistics ALWAYS (when a strategy has a
2318
+ # position) and cache them on ``self.stats`` so callers such as
2319
+ # ``pyne optimize`` can read ``runner.stats`` after ``run()`` even
2320
+ # when no strat CSV writer was passed. Write to CSV only if a
2321
+ # strat writer exists.
2322
+ if is_strat and position:
2323
+ self.stats = calculate_strategy_statistics(
2324
+ position,
2325
+ self.script.initial_capital,
2326
+ self.equity_curve if self.equity_curve else None,
2327
+ self.first_price,
2328
+ self.last_price
2329
+ )
2330
+ if self.strat_writer:
2331
+ try:
2332
+ self.strat_writer.open()
2333
+ write_strategy_statistics_csv(self.stats, self.strat_writer)
2334
+ finally:
2335
+ # Close strat writer
2336
+ self.strat_writer.close()
2337
+
2338
+ # Close the plot writer
2339
+ if self.plot_writer:
2340
+ self.plot_writer.close()
2341
+ # Close the trade writer
2342
+ if self.trades_writer:
2343
+ self.trades_writer.close()
2344
+
2345
+ # Shutdown security processes
2346
+ if sec_processes and sec_states is not None:
2347
+ for state in sec_states.values():
2348
+ state.stop_event.set()
2349
+ state.advance_event.set() # wake up if waiting
2350
+ for p in sec_processes.values():
2351
+ p.join(timeout=5)
2352
+ if p.is_alive():
2353
+ p.terminate()
2354
+ if callable(sec_cleanup_fn):
2355
+ sec_cleanup_fn: Callable
2356
+ sec_cleanup_fn()
2357
+ if sec_sync_block and sec_result_blocks:
2358
+ from .security import cleanup_shared_memory
2359
+ cleanup_shared_memory(sec_sync_block, sec_result_blocks)
2360
+
2361
+ # Remove temp dirs created for HTF security-feed resampling.
2362
+ if sec_resample_dirs:
2363
+ import shutil
2364
+ for _tmp_dir in sec_resample_dirs:
2365
+ shutil.rmtree(_tmp_dir, ignore_errors=True)
2366
+
2367
+ # Cancel the broker event-stream task scheduled in __init__.
2368
+ # Done before loop teardown so the watch_orders generator gets
2369
+ # a chance to clean up its HTTP session.
2370
+ if self._engine_event_stream_future is not None:
2371
+ self._engine_event_stream_future.cancel()
2372
+ self._engine_event_stream_future = None
2373
+
2374
+ # Finalize the viz writer: a full drawings snapshot, then the end
2375
+ # record. Drawing registries are still populated here (they are only
2376
+ # reset at run-start), so the snapshot reflects the final state.
2377
+ # Guard against an exception before the writer was ever opened.
2378
+ if self.viz_writer is not None and self.viz_writer.is_open:
2379
+ try:
2380
+ self.viz_writer.write_drawings_snapshot()
2381
+ self.viz_writer.write_end(self.viz_writer.bars)
2382
+ finally:
2383
+ self.viz_writer.close()
2384
+
2385
+ # Reset library variables
2386
+ _reset_lib_vars()
2387
+ # Drop function instances and this run's root vectors
2388
+ instance_state.reset()
2389
+ for root_key in root_keys:
2390
+ instance_state.discard_root(root_key)
2391
+
2392
+ # noinspection PyProtectedMember
2393
+ def _run_iter_magnified(self, lib, barstate, position, run_main, lib_mains, var_snapshot,
2394
+ is_strat, on_progress, string):
2395
+ """
2396
+ Magnified bar iteration: iterate sub-TF windows, process orders at sub-bar
2397
+ resolution, execute script once per chart bar.
2398
+ """
2399
+ from .bar_magnifier import BarMagnifier
2400
+ # Needed for COOF re-execution path (already loaded by run_iter, safe to re-import)
2401
+ from pynecore.core import instance_state
2402
+
2403
+ chart_tf = str(lib.syminfo.period)
2404
+ assert self._magnifier_iter is not None
2405
+ magnifier = BarMagnifier(self._magnifier_iter, chart_tf, tz=self.tz,
2406
+ session_starts=self.syminfo.session_starts,
2407
+ opening_hours=self.syminfo.opening_hours,
2408
+ sym_type=self.syminfo.type,
2409
+ source_tf=self._magnifier_source_tf)
2410
+
2411
+ trade_num = 0
2412
+
2413
+ for window in magnifier:
2414
+ # Pre-increment: bar_index becomes the index of the current
2415
+ # aggregated chart bar.
2416
+ self.bar_index += 1
2417
+
2418
+ barstate.islast = window.is_last_window
2419
+
2420
+ # Set lib OHLCV to the aggregated chart-bar values (what the script sees)
2421
+ _set_lib_properties(window.aggregated, self.bar_index, self.tz, lib, self._round_decimals)
2422
+
2423
+ # Store first price for buy & hold calculation
2424
+ if self.first_price is None:
2425
+ self.first_price = lib.close # type: ignore
2426
+
2427
+ # Update last price
2428
+ self.last_price = lib.close # type: ignore
2429
+
2430
+ # Process orders against each sub-bar for accurate fills
2431
+ if var_snapshot and position:
2432
+ if var_snapshot.has_vars:
2433
+ var_snapshot.save()
2434
+
2435
+ old_fills = position._fill_counter
2436
+ position.process_orders_magnified(window.sub_bars, window.aggregated)
2437
+ new_fills = position._fill_counter
2438
+
2439
+ while new_fills > old_fills:
2440
+ if var_snapshot.has_vars:
2441
+ var_snapshot.restore()
2442
+ instance_state.reset()
2443
+ lib._lib_semaphore = True
2444
+ for run_lib_main in lib_mains:
2445
+ run_lib_main()
2446
+ lib._lib_semaphore = False
2447
+ run_main()
2448
+ old_fills = new_fills
2449
+ position.process_orders_magnified(window.sub_bars, window.aggregated)
2450
+ new_fills = position._fill_counter
2451
+
2452
+ if var_snapshot.has_vars:
2453
+ var_snapshot.restore()
2454
+ elif position:
2455
+ position.process_orders_magnified(window.sub_bars, window.aggregated)
2456
+
2457
+ # Execute registered library main functions before main script
2458
+ lib._lib_semaphore = True
2459
+ for run_lib_main in lib_mains:
2460
+ run_lib_main()
2461
+ lib._lib_semaphore = False
2462
+
2463
+ # Run the script
2464
+ res = run_main()
2465
+
2466
+ # Fill immediate closes enqueued during the body, at this bar's close —
2467
+ # after the body (magnified is backtest-only, position is SimPosition).
2468
+ if position:
2469
+ position.settle_immediate_closes()
2470
+
2471
+ # Pine `process_orders_on_close=true` — extra fill attempt at the bar
2472
+ # close for current-bar orders. No COOF re-run: Pine disables
2473
+ # `calc_on_order_fills` when this flag is set (var_snapshot is None
2474
+ # whenever both are true).
2475
+ if position and self.script.process_orders_on_close:
2476
+ position.process_orders_at_close()
2477
+
2478
+ # Process deferred margin calls (after script runs, before results)
2479
+ if position:
2480
+ position.process_deferred_margin_call()
2481
+
2482
+ # Update plot data with the results
2483
+ if res is not None:
2484
+ assert isinstance(res, dict), "The 'main' function must return a dictionary!"
2485
+ lib._plot_data.update(res)
2486
+
2487
+ # Write plot data to CSV if we have a writer
2488
+ if self.plot_writer and lib._plot_data:
2489
+ extra_fields = {} if window.aggregated.extra_fields is None \
2490
+ else dict(window.aggregated.extra_fields)
2491
+ extra_fields.update(lib._plot_data)
2492
+ updated_candle = window.aggregated._replace(extra_fields=extra_fields)
2493
+ self.plot_writer.write_ohlcv(updated_candle)
2494
+
2495
+ # Write visual data (plot styles + drawings) for this aggregated bar
2496
+ self._write_viz_bar(window.aggregated)
2497
+
2498
+ # Yield results
2499
+ if not is_strat:
2500
+ yield window.aggregated, lib._plot_data
2501
+ elif position:
2502
+ yield window.aggregated, lib._plot_data, position.new_closed_trades
2503
+
2504
+ # Save trade data
2505
+ if is_strat and self.trades_writer and position:
2506
+ for trade in position.new_closed_trades:
2507
+ trade_num += 1
2508
+ self.trades_writer.write(
2509
+ trade_num,
2510
+ trade.entry_bar_index,
2511
+ "Entry long" if trade.size > 0 else "Entry short",
2512
+ trade.entry_comment if trade.entry_comment else trade.entry_id,
2513
+ string.format_time(trade.entry_time), # type: ignore
2514
+ trade.entry_price,
2515
+ abs(trade.size),
2516
+ trade.profit,
2517
+ f"{trade.profit_percent:.2f}",
2518
+ trade.cum_profit,
2519
+ f"{trade.cum_profit_percent:.2f}",
2520
+ trade.max_runup,
2521
+ f"{trade.max_runup_percent:.2f}",
2522
+ trade.max_drawdown,
2523
+ f"{trade.max_drawdown_percent:.2f}",
2524
+ )
2525
+ self.trades_writer.write(
2526
+ trade_num,
2527
+ trade.exit_bar_index,
2528
+ "Exit long" if trade.size > 0 else "Exit short",
2529
+ trade.exit_comment if trade.exit_comment else trade.exit_id,
2530
+ string.format_time(trade.exit_time), # type: ignore
2531
+ trade.exit_price,
2532
+ abs(trade.size),
2533
+ trade.profit,
2534
+ f"{trade.profit_percent:.2f}",
2535
+ trade.cum_profit,
2536
+ f"{trade.cum_profit_percent:.2f}",
2537
+ trade.max_runup,
2538
+ f"{trade.max_runup_percent:.2f}",
2539
+ trade.max_drawdown,
2540
+ f"{trade.max_drawdown_percent:.2f}",
2541
+ )
2542
+
2543
+ # Clear plot data
2544
+ lib._plot_data.clear()
2545
+ lib._viz_dyn.clear()
2546
+ lib._viz_seq.clear()
2547
+
2548
+ # Track equity curve for strategies
2549
+ if is_strat and position:
2550
+ current_equity = float(position.equity) if position.equity else self.script.initial_capital
2551
+ self.equity_curve.append(current_equity)
2552
+
2553
+ # Call the progress callback
2554
+ if on_progress and lib._datetime is not None:
2555
+ on_progress(lib._datetime.replace(tzinfo=None))
2556
+
2557
+ # It is no longer the first bar
2558
+ barstate.isfirst = False
2559
+
2560
+ if on_progress:
2561
+ on_progress(datetime.max)
2562
+
2563
+ # noinspection PyProtectedMember
2564
+ def list_data_requirements(
2565
+ self, *, chart_symbol: str, chart_tf: str,
2566
+ security_keys: set[str] | None = None,
2567
+ ) -> DataRequirements:
2568
+ """Statically classify the script's external data dependencies.
2569
+
2570
+ Merges ``__security_contexts__`` from the main script module and every
2571
+ registered library module, then buckets each context the same way
2572
+ :meth:`run_iter` does (same-context vs. static vs. deferred) without
2573
+ spawning processes, opening data files, or calling
2574
+ :meth:`_resolve_security_data` (which would raise on unmapped backtest
2575
+ contexts). It only inspects whether a matching ``--security`` key is
2576
+ present, so it never raises.
2577
+
2578
+ :param chart_symbol: The chart's ``PREFIX:TICKER`` (matches what
2579
+ ``_set_lib_syminfo_properties`` stores in ``lib.syminfo.ticker``).
2580
+ :param chart_tf: The chart's timeframe (``lib.syminfo.period``).
2581
+ :param security_keys: The keys of the user-provided ``--security``
2582
+ mappings, used to flag which contexts already have a data file.
2583
+ :return: A :class:`DataRequirements` with the four classified buckets.
2584
+ """
2585
+ from . import script
2586
+
2587
+ keys = security_keys or set()
2588
+
2589
+ # Merge contexts from the script module and every registered library
2590
+ # module — sec ids carry a module hash so they cannot collide. Track
2591
+ # which ids came from a library so the report can flag them.
2592
+ merged: dict[str, tuple[dict, bool]] = {}
2593
+
2594
+ def _absorb(mod: ModuleType, from_lib: bool) -> None:
2595
+ ctxs: dict[str, dict] | None = getattr(mod, '__security_contexts__', None)
2596
+ if ctxs:
2597
+ for _sid, _ctx in ctxs.items():
2598
+ merged[_sid] = (_ctx, from_lib)
2599
+
2600
+ _absorb(self.script_module, False)
2601
+ for _lib_title, _lib_main in script._registered_libraries:
2602
+ _mod_name = getattr(_lib_main, '__module__', '')
2603
+ if _mod_name not in sys.modules:
2604
+ continue
2605
+ _lib_mod = sys.modules[_mod_name]
2606
+ if _lib_mod is not self.script_module:
2607
+ _absorb(_lib_mod, True)
2608
+
2609
+ chart_main: list[SecurityRequirement] = []
2610
+ same_symbol_other_tf: list[SecurityRequirement] = []
2611
+ cross_symbol: list[SecurityRequirement] = []
2612
+ dynamic: list[SecurityRequirement] = []
2613
+
2614
+ for sec_id, (ctx, from_library) in merged.items():
2615
+ sym = ctx.get('symbol')
2616
+ tf_val = ctx.get('timeframe', chart_tf)
2617
+ # An empty-string timeframe selects the chart's timeframe (Pine
2618
+ # semantics); a None timeframe stays runtime-deferred.
2619
+ if tf_val == '':
2620
+ tf_val = chart_tf
2621
+ is_ltf = bool(ctx.get('is_ltf'))
2622
+ ignore_invalid = bool(ctx.get('ignore_invalid_symbol'))
2623
+
2624
+ if sym is None or tf_val is None:
2625
+ dynamic.append(SecurityRequirement(
2626
+ sec_id=sec_id, symbol=None if sym is None else str(sym),
2627
+ timeframe=None if tf_val is None else str(tf_val),
2628
+ is_ltf=is_ltf, ignore_invalid_symbol=ignore_invalid,
2629
+ from_library=from_library, has_security_mapping=False,
2630
+ ))
2631
+ continue
2632
+
2633
+ sym_str = str(sym)
2634
+ tf_str = str(tf_val)
2635
+ # Mirror _resolve_security_data's key precedence: "SYMBOL:TF",
2636
+ # then "SYMBOL", then "TF".
2637
+ has_mapping = (
2638
+ f"{sym_str}:{tf_str}" in keys
2639
+ or sym_str in keys
2640
+ or tf_str in keys
2641
+ )
2642
+ is_cross_symbol = sym_str != chart_symbol
2643
+ # Global map + derived-file status are only meaningful for
2644
+ # cross-symbol requirements (same-symbol feeds resample from the
2645
+ # chart data). Only compute the disk-scan for those.
2646
+ map_fields = (self._describe_global_map(sym_str, tf_str)
2647
+ if is_cross_symbol else {})
2648
+ req = SecurityRequirement(
2649
+ sec_id=sec_id, symbol=sym_str, timeframe=tf_str, is_ltf=is_ltf,
2650
+ ignore_invalid_symbol=ignore_invalid, from_library=from_library,
2651
+ has_security_mapping=has_mapping,
2652
+ **map_fields,
2653
+ )
2654
+ if sym_str == chart_symbol and tf_str == chart_tf:
2655
+ chart_main.append(req)
2656
+ elif sym_str == chart_symbol:
2657
+ same_symbol_other_tf.append(req)
2658
+ else:
2659
+ cross_symbol.append(req)
2660
+
2661
+ def _sort_key(r: SecurityRequirement) -> tuple[str, str]:
2662
+ return r.symbol or '', r.timeframe or ''
2663
+
2664
+ return DataRequirements(
2665
+ chart_symbol=chart_symbol, chart_tf=chart_tf,
2666
+ chart_main=sorted(chart_main, key=_sort_key),
2667
+ same_symbol_other_tf=sorted(same_symbol_other_tf, key=_sort_key),
2668
+ cross_symbol=sorted(cross_symbol, key=_sort_key),
2669
+ dynamic=sorted(dynamic, key=_sort_key),
2670
+ )
2671
+
2672
+ def _data_dir(self) -> 'Path | None':
2673
+ """Return the workdir data directory, if derivable.
2674
+
2675
+ Backtest: the parent of the chart's own ``.ohlcv`` file. Live: the
2676
+ chart provider's OHLCV dir. ``None`` when neither is available.
2677
+ """
2678
+ if self._chart_data_path is not None:
2679
+ return Path(self._chart_data_path).parent
2680
+ return self._chart_ohlcv_dir()
2681
+
2682
+ def _describe_global_map(self, symbol: str, timeframe: str) -> dict:
2683
+ """Build the global-map report fields for one cross-symbol requirement.
2684
+
2685
+ Returns a kwargs dict for :class:`SecurityRequirement`: the global-map
2686
+ hit (provider + native symbol), the derived ``.ohlcv`` file and whether
2687
+ it exists, a ready-to-run download suggestion for a mapped-but-missing
2688
+ file, and — as a fallback when unmapped — existing data-dir files whose
2689
+ ticker matches (ignoring the exchange prefix).
2690
+ """
2691
+ data_dir = self._data_dir()
2692
+ mapped = self._symbol_map.resolve(symbol, timeframe or None)
2693
+ if mapped is None:
2694
+ return {'file_suggestions': self._scan_ticker_suggestions(symbol, data_dir)}
2695
+ expected = self._mapped_ohlcv_path(mapped, timeframe, data_dir)
2696
+ exists = bool(expected is not None and expected.exists())
2697
+ download_suggestion = None
2698
+ if expected is not None and not exists:
2699
+ download_suggestion = (
2700
+ f"pyne data download "
2701
+ f"'{mapped.provider}:{mapped.native_symbol}@{timeframe}'"
2702
+ )
2703
+ return {
2704
+ 'has_global_map': True,
2705
+ 'mapped_provider': mapped.provider,
2706
+ 'mapped_native_symbol': mapped.native_symbol,
2707
+ 'mapped_file': str(expected) if expected is not None else None,
2708
+ 'mapped_file_exists': exists,
2709
+ 'download_suggestion': download_suggestion,
2710
+ }
2711
+
2712
+ @staticmethod
2713
+ def _mapped_ohlcv_path(mapped: 'MappedSymbol', timeframe: str,
2714
+ data_dir: 'Path | None') -> 'Path | None':
2715
+ """Derive the expected ``.ohlcv`` path for a global-map hit.
2716
+
2717
+ Uses the mapped provider's own ``get_ohlcv_path`` (a classmethod, so
2718
+ per-provider naming overrides are honored). Returns ``None`` when the
2719
+ data dir is unknown or the provider plugin cannot be loaded.
2720
+ """
2721
+ if data_dir is None:
2722
+ return None
2723
+ from .plugin import load_plugin
2724
+ from .plugin.provider import ProviderPlugin
2725
+ try:
2726
+ provider_cls = load_plugin(mapped.provider)
2727
+ except Exception: # noqa: BLE001 - unknown/uninstalled provider
2728
+ return None
2729
+ if not (isinstance(provider_cls, type) and issubclass(provider_cls, ProviderPlugin)):
2730
+ return None
2731
+ return provider_cls.get_ohlcv_path(
2732
+ mapped.native_symbol, timeframe, data_dir,
2733
+ provider_name=mapped.provider)
2734
+
2735
+ def _scan_ticker_suggestions(self, symbol: str, data_dir: 'Path | None') -> list[str]:
2736
+ """Return existing ``.ohlcv`` stems whose ticker matches ``symbol``.
2737
+
2738
+ Scans the data dir's sibling syminfo ``.toml`` files and matches on
2739
+ ``[symbol].ticker`` ignoring the exchange prefix (case-insensitive), so
2740
+ e.g. ``NASDAQ:AAPL`` suggests a ``capitalcom_AAPL_1D.ohlcv`` file whose
2741
+ toml records ticker ``AAPL``.
2742
+ """
2743
+ if data_dir is None or not data_dir.is_dir():
2744
+ return []
2745
+ want = symbol.rsplit(':', 1)[-1].strip().upper()
2746
+ if not want:
2747
+ return []
2748
+ out: list[str] = []
2749
+ for toml_path in sorted(data_dir.glob('*.toml')):
2750
+ ohlcv_path = toml_path.with_suffix('.ohlcv')
2751
+ if not ohlcv_path.exists():
2752
+ continue
2753
+ try:
2754
+ with open(toml_path, 'rb') as f:
2755
+ data = tomllib.load(f)
2756
+ except (OSError, tomllib.TOMLDecodeError):
2757
+ continue
2758
+ sym = data.get('symbol')
2759
+ if not isinstance(sym, dict):
2760
+ continue
2761
+ ticker = sym.get('ticker')
2762
+ if isinstance(ticker, str) and ticker.strip().upper() == want:
2763
+ out.append(ohlcv_path.stem)
2764
+ return out
2765
+
2766
+ def _resolve_security_data(self, contexts: dict) -> 'dict[str, str | PluginSymbol | None]':
2767
+ """
2768
+ Resolve a data source for each security context.
2769
+
2770
+ Walks the user-provided ``security_data`` dictionary first, matching
2771
+ on ``"SYMBOL:TF"``, then ``"SYMBOL"``, then ``"TF"`` keys. Falls
2772
+ through to two mode-specific behaviours when no explicit mapping
2773
+ exists:
2774
+
2775
+ - **Live mode** (chart provider available): builds a
2776
+ :class:`PluginSymbol` for the security subprocess by translating
2777
+ the Pine-style symbol through ``chart_provider_instance.resolve_symbol``
2778
+ (which consults the plugin's ``config.symbol_map`` TOML table
2779
+ first, falling back to ``normalize_symbol``).
2780
+ - **Backtest mode** (no chart provider): raises ``ValueError`` —
2781
+ a security context cannot be resolved without either an explicit
2782
+ ``--security`` file mapping or ``ignore_invalid_symbol``.
2783
+
2784
+ :param contexts: The ``__security_contexts__`` dict from the script module
2785
+ :return: Dict mapping sec_id to an OHLCV file path (``str``), a
2786
+ :class:`PluginSymbol` for live-mode subprocesses, or
2787
+ ``None`` when the context was opted out via
2788
+ ``ignore_invalid_symbol``.
2789
+ :raises ValueError: If no data found and ignore_invalid_symbol is not True
2790
+ """
2791
+ from dataclasses import replace as dc_replace
2792
+ from ..lib.ticker import _split_chart_type
2793
+ result: dict[str, str | PluginSymbol | None] = {}
2794
+ for sec_id, ctx in contexts.items():
2795
+ # Strip any chart-type marker (``ticker.heikinashi()``) so the data
2796
+ # source resolves on the base symbol; the child applies the transform
2797
+ # per bar from ``SecurityState.chart_type``.
2798
+ symbol, chart_type = _split_chart_type(str(ctx.get('symbol', '')))
2799
+ timeframe = str(ctx.get('timeframe', ''))
2800
+
2801
+ entry: str | Path | PluginSymbol | None = None
2802
+ # Try exact "SYMBOL:TF" match, then symbol-only, then TF-only.
2803
+ key = f"{symbol}:{timeframe}"
2804
+ if key in self._security_data:
2805
+ entry = self._security_data[key]
2806
+ elif symbol in self._security_data:
2807
+ entry = self._security_data[symbol]
2808
+ elif timeframe in self._security_data:
2809
+ entry = self._security_data[timeframe]
2810
+
2811
+ if isinstance(entry, PluginSymbol):
2812
+ if entry.time_from is None and self._time_from is not None:
2813
+ entry = dc_replace(entry, time_from=self._time_from)
2814
+ result[sec_id] = cast('PluginSymbol', entry)
2815
+ continue
2816
+ if entry is not None:
2817
+ result[sec_id] = self._ensure_ohlcv_ext(entry)
2818
+ continue
2819
+
2820
+ # Chart-type request (Heikin Ashi) on the chart's own symbol with no
2821
+ # explicit ``--security`` mapping (backtest): use the chart's own feed
2822
+ # as the source; the child applies the HA transform per bar. In live
2823
+ # mode ``_chart_provider_instance`` is set, so this falls through to
2824
+ # the chart-provider branch, which yields a ``PluginSymbol`` the child
2825
+ # streams and transforms the same way.
2826
+ if (chart_type is not None
2827
+ and self._chart_provider_instance is None
2828
+ and self._chart_data_path is not None
2829
+ and symbol == f"{self.syminfo.prefix}:{self.syminfo.ticker}"):
2830
+ result[sec_id] = str(self._chart_data_path)
2831
+ continue
2832
+
2833
+ # Same-symbol request on the chart's own symbol at a different
2834
+ # (coarser) timeframe with no explicit ``--security`` mapping
2835
+ # (backtest): serve from the chart's own feed — the child pre-resamples
2836
+ # it to the security period via ``_resample_finer_security_feed``.
2837
+ # LTF (finer than the chart) genuinely needs sub-bars the chart feed
2838
+ # cannot supply, so it is excluded and falls through to the error.
2839
+ if (chart_type is None
2840
+ and not ctx.get('is_ltf')
2841
+ and self._chart_provider_instance is None
2842
+ and self._chart_data_path is not None
2843
+ and symbol == f"{self.syminfo.prefix}:{self.syminfo.ticker}"):
2844
+ result[sec_id] = str(self._chart_data_path)
2845
+ continue
2846
+
2847
+ # Global workdir symbol_map.toml (backtest): translate the
2848
+ # TradingView-style symbol to a provider-native one and derive the
2849
+ # expected ``.ohlcv`` file. This overrides the identity live-provider
2850
+ # fallback but is itself overridden by an explicit ``--security``
2851
+ # mapping and by the chart-symbol branches above.
2852
+ if self._chart_provider_instance is None:
2853
+ mapped = self._symbol_map.resolve(symbol, timeframe or None)
2854
+ if mapped is not None:
2855
+ tf_for_file = timeframe or str(self.syminfo.period)
2856
+ data_dir = self._data_dir()
2857
+ expected = self._mapped_ohlcv_path(mapped, tf_for_file, data_dir)
2858
+ if expected is not None and expected.exists():
2859
+ result[sec_id] = str(expected)
2860
+ continue
2861
+ if ctx.get('ignore_invalid_symbol'):
2862
+ result[sec_id] = None
2863
+ continue
2864
+ if expected is not None:
2865
+ raise ValueError(
2866
+ f"Security {symbol!r} @ {tf_for_file!r} is mapped to "
2867
+ f"{mapped.provider}:{mapped.native_symbol!r} by "
2868
+ f"config/symbol_map.toml, but the derived data file "
2869
+ f"{expected.name} was not found in {expected.parent}. "
2870
+ f"Download it with: pyne data download "
2871
+ f"'{mapped.provider}:{mapped.native_symbol}@{tf_for_file}'"
2872
+ )
2873
+
2874
+ # No explicit mapping — fall back to chart-provider resolution
2875
+ # in live mode.
2876
+ if self._chart_provider_instance is not None and self._chart_provider_name:
2877
+ native_symbol = self._chart_provider_instance.resolve_symbol(symbol)
2878
+ result[sec_id] = PluginSymbol(
2879
+ provider_name=self._chart_provider_name,
2880
+ symbol=native_symbol,
2881
+ timeframe=timeframe,
2882
+ config=getattr(self._chart_provider_instance, 'config', None),
2883
+ time_from=self._time_from,
2884
+ ohlcv_dir=self._chart_ohlcv_dir(),
2885
+ )
2886
+ continue
2887
+
2888
+ # No data found — check if ignore_invalid_symbol is set
2889
+ if ctx.get('ignore_invalid_symbol'):
2890
+ result[sec_id] = None
2891
+ continue
2892
+
2893
+ raise ValueError(
2894
+ f"No OHLCV data found for security context "
2895
+ f"(symbol={symbol!r}, timeframe={timeframe!r}). "
2896
+ f"Provide data via the security_data parameter, e.g.: "
2897
+ f"security_data={{'{symbol}': 'path/to/data.ohlcv'}}"
2898
+ )
2899
+ return result
2900
+
2901
+ def _prefetch_sec_syminfos(
2902
+ self,
2903
+ sec_data: 'dict[str, str | PluginSymbol | None]',
2904
+ sec_contexts: dict | None = None,
2905
+ ) -> 'dict[str, str | PluginSymbol | None]':
2906
+ """Pre-fetch :class:`SymInfo` for every live-mode security context.
2907
+
2908
+ Builds a temporary :class:`LiveProviderPlugin` instance for each
2909
+ :class:`PluginSymbol` entry and calls ``update_symbol_info()`` once
2910
+ from the chart process. The result is cached on ``self._sec_syminfos``
2911
+ (used by the currency-rate plumbing) and folded back into the
2912
+ returned :class:`PluginSymbol` so the subprocess does not have to
2913
+ repeat the REST round-trip on startup.
2914
+
2915
+ File-mode entries (backtest) are returned unchanged.
2916
+
2917
+ :param sec_data: Per-sec_id resolved data sources (mutated to None
2918
+ for sec_ids whose REST lookup fails and whose context opted in
2919
+ via ``ignore_invalid_symbol=True``).
2920
+ :param sec_contexts: ``__security_contexts__`` dict — consulted to
2921
+ honor ``ignore_invalid_symbol`` when a symbol fails to resolve.
2922
+ When ``None``, every failure propagates as an exception.
2923
+ """
2924
+ from dataclasses import replace as dc_replace
2925
+ from pynecore.core.plugin.live_provider import LiveProviderPlugin
2926
+ from pynecore.core.plugin import load_plugin
2927
+
2928
+ out: dict[str, str | PluginSymbol | None] = {}
2929
+ for sec_id, entry in sec_data.items():
2930
+ if not isinstance(entry, PluginSymbol):
2931
+ # File-mode (backtest) source: cache the security's OWN syminfo
2932
+ # from the sibling ``.toml`` so the session-anchor decision in
2933
+ # ``setup_security_states`` aligns the HTF grid to the security
2934
+ # symbol's exchange session rather than falling back to the
2935
+ # chart's session.
2936
+ if isinstance(entry, (str, Path)) and sec_id not in self._sec_syminfos:
2937
+ # ``entry`` is a stem or an ``.ohlcv`` path; a dot inside the
2938
+ # name belongs to the symbol (e.g. a perpetual ``BTCUSDT.P``),
2939
+ # so swap the extension by name, not via ``with_suffix``.
2940
+ _entry = Path(entry)
2941
+ _stem = (_entry.name[:-len('.ohlcv')]
2942
+ if _entry.name.endswith('.ohlcv') else _entry.name)
2943
+ sec_toml = _entry.with_name(_stem + '.toml')
2944
+ if sec_toml.exists():
2945
+ self._sec_syminfos[sec_id] = SymInfo.load_toml(sec_toml)
2946
+ out[sec_id] = entry
2947
+ continue
2948
+ if entry.syminfo is not None:
2949
+ self._sec_syminfos[sec_id] = entry.syminfo
2950
+ out[sec_id] = entry
2951
+ continue
2952
+ provider_cls = load_plugin(entry.provider_name)
2953
+ if not issubclass(provider_cls, LiveProviderPlugin):
2954
+ raise RuntimeError(
2955
+ f"Plugin '{entry.provider_name}' is not a live provider; "
2956
+ f"cannot drive cross-symbol live request.security."
2957
+ )
2958
+ ignore_invalid = bool(
2959
+ sec_contexts and sec_contexts.get(sec_id, {}).get('ignore_invalid_symbol')
2960
+ )
2961
+ # Constructor and ``update_symbol_info`` both share the
2962
+ # ``ignore_invalid_symbol`` downgrade: some live providers (e.g.
2963
+ # CCXT) validate the exchange prefix in ``__init__`` and raise
2964
+ # before the symbol-info call ever runs.
2965
+ # noinspection PyBroadException
2966
+ try:
2967
+ provider = provider_cls(
2968
+ symbol=entry.symbol,
2969
+ timeframe=entry.timeframe,
2970
+ ohlcv_dir=entry.ohlcv_dir,
2971
+ config=entry.config,
2972
+ )
2973
+ syminfo = provider.update_symbol_info()
2974
+ except Exception: # noqa: BLE001
2975
+ if not ignore_invalid:
2976
+ raise
2977
+ # ``ignore_invalid_symbol=True``: downgrade to the
2978
+ # backtest-mode "no data" sentinel so the rest of the
2979
+ # pipeline treats this context as ignored.
2980
+ out[sec_id] = None
2981
+ continue
2982
+ self._sec_syminfos[sec_id] = syminfo
2983
+ out[sec_id] = dc_replace(entry, syminfo=syminfo)
2984
+ return out
2985
+
2986
+ def _autospawn_rate_sources(
2987
+ self,
2988
+ sec_contexts: dict,
2989
+ static_contexts: dict,
2990
+ sec_ohlcv_paths: 'dict[str, str | PluginSymbol | None]',
2991
+ chart_tf: str,
2992
+ ) -> None:
2993
+ """Discover and spawn rate-source contexts for unresolved ``currency=X`` pairs.
2994
+
2995
+ For every security context whose ``currency`` parameter would
2996
+ require a ``(basecurrency, target_currency)`` exchange-rate lookup
2997
+ not already covered by the chart pair or by an existing security
2998
+ context, builds a hidden rate-source :class:`PluginSymbol` (with
2999
+ ``is_rate_source=True``) and adds it to ``sec_contexts`` /
3000
+ ``static_contexts`` / ``sec_ohlcv_paths``. The chart's own provider
3001
+ instance is used to validate the constructed pair symbol via
3002
+ ``update_symbol_info()`` — invalid symbols are skipped silently
3003
+ (the rate downstream simply remains ``NaN``).
3004
+
3005
+ Backtest runs (no chart-side live provider) leave everything
3006
+ untouched; the legacy ``.toml`` lookup keeps working.
3007
+ """
3008
+ if self._chart_provider_instance is None or not self._chart_provider_name:
3009
+ return
3010
+
3011
+ chart_pair: tuple[str, str] | None = None
3012
+ if self.syminfo.basecurrency:
3013
+ chart_pair = (self.syminfo.basecurrency, self.syminfo.currency)
3014
+
3015
+ # Only the chart pair (whose ``lib.close`` is the live rate) and
3016
+ # other explicit rate sources count as "already covered". User
3017
+ # security contexts are *not* assumed to expose close — their
3018
+ # ResultBlock carries the user's ``request.security()`` expression
3019
+ # result, which can be anything (e.g. ``ta.sma(close, 20)``, ``high``,
3020
+ # a tuple). Treating those as FX rates would silently misuse
3021
+ # indicator values as exchange rates.
3022
+ existing_pairs: set[tuple[str, str]] = set()
3023
+ if chart_pair is not None:
3024
+ existing_pairs.add(chart_pair)
3025
+ existing_pairs.add((chart_pair[1], chart_pair[0]))
3026
+ for _sid, ps in sec_ohlcv_paths.items():
3027
+ if (isinstance(ps, PluginSymbol) and ps.is_rate_source
3028
+ and ps.syminfo and ps.syminfo.basecurrency):
3029
+ existing_pairs.add((ps.syminfo.basecurrency, ps.syminfo.currency))
3030
+ existing_pairs.add((ps.syminfo.currency, ps.syminfo.basecurrency))
3031
+
3032
+ # Collect pairs that need an auto-rate-source.
3033
+ needed_pairs: set[tuple[str, str]] = set()
3034
+ for sid, ctx in sec_contexts.items():
3035
+ target_cur = ctx.get('currency')
3036
+ if target_cur is None:
3037
+ continue
3038
+ target_str = str(target_cur)
3039
+ if not target_str or target_str.lower() in ('na', 'nan', ''):
3040
+ continue
3041
+ si = self._sec_syminfos.get(sid)
3042
+ if si is None or not si.currency:
3043
+ continue
3044
+ from_cur, to_cur = si.currency, target_str
3045
+ if from_cur == to_cur:
3046
+ continue
3047
+ if (from_cur, to_cur) in existing_pairs:
3048
+ continue
3049
+ needed_pairs.add((from_cur, to_cur))
3050
+
3051
+ if not needed_pairs:
3052
+ return
3053
+
3054
+ from pynecore.core.plugin import load_plugin
3055
+ from pynecore.core.plugin.live_provider import LiveProviderPlugin
3056
+
3057
+ provider_cls = load_plugin(self._chart_provider_name)
3058
+ if not issubclass(provider_cls, LiveProviderPlugin):
3059
+ return
3060
+ config = getattr(self._chart_provider_instance, 'config', None)
3061
+
3062
+ symbol_map = getattr(config, 'symbol_map', None) or {}
3063
+
3064
+ def _try_pair(a: str, b: str) -> 'tuple[str, SymInfo] | None':
3065
+ """Try to resolve ``construct_pair_symbol(a, b)``; return the
3066
+ ``(native_symbol, syminfo)`` tuple if the provider exposes the
3067
+ currency pair (in either direction), else ``None``.
3068
+ """
3069
+ pk = cast('type[LiveProviderPlugin]', provider_cls).construct_pair_symbol(a, b)
3070
+ ns = self._chart_provider_instance.resolve_symbol(pk)
3071
+ # noinspection PyBroadException
3072
+ try:
3073
+ tp = provider_cls(
3074
+ symbol=ns,
3075
+ timeframe=chart_tf,
3076
+ ohlcv_dir=self._chart_ohlcv_dir(),
3077
+ config=config,
3078
+ )
3079
+ pair_si = tp.update_symbol_info()
3080
+ except Exception: # noqa: BLE001
3081
+ return None
3082
+ act = (pair_si.basecurrency, pair_si.currency)
3083
+ if act != (a, b) and act != (b, a):
3084
+ return None
3085
+ return ns, pair_si
3086
+
3087
+ for from_cur, to_cur in sorted(needed_pairs):
3088
+ # A prior iteration may have already spawned a rate source for
3089
+ # the inverse direction of this pair; ``CurrencyRateProvider``
3090
+ # inverts rates transparently, so a second feed for the same
3091
+ # underlying pair would just duplicate WS subscriptions.
3092
+ if (from_cur, to_cur) in existing_pairs:
3093
+ continue
3094
+ # Try the direct ``from_cur + to_cur`` construction first. If the
3095
+ # provider exposes only the inverse pair (e.g. ``EURUSD`` is live
3096
+ # but the script requested USD→EUR), fall back to the inverse
3097
+ # construction — ``CurrencyRateProvider`` already inverts rates
3098
+ # from a reverse-direction source. The fallback is skipped when a
3099
+ # ``symbol_map`` already maps the direct Pine key, so user-provided
3100
+ # explicit mappings are trusted as-is.
3101
+ direct_pinekey = provider_cls.construct_pair_symbol(from_cur, to_cur)
3102
+ resolved = _try_pair(from_cur, to_cur)
3103
+ if resolved is None and direct_pinekey not in symbol_map:
3104
+ resolved = _try_pair(to_cur, from_cur)
3105
+ if resolved is None:
3106
+ continue
3107
+ native_symbol, syminfo = resolved
3108
+ auto_sec_id = f"__auto_rate_{from_cur}_{to_cur}__"
3109
+ if auto_sec_id in sec_contexts:
3110
+ continue
3111
+ ps = PluginSymbol(
3112
+ provider_name=self._chart_provider_name,
3113
+ symbol=native_symbol,
3114
+ timeframe=chart_tf,
3115
+ config=config,
3116
+ time_from=self._time_from,
3117
+ syminfo=syminfo,
3118
+ is_rate_source=True,
3119
+ ohlcv_dir=self._chart_ohlcv_dir(),
3120
+ )
3121
+ sec_contexts[auto_sec_id] = {
3122
+ 'symbol': native_symbol,
3123
+ 'timeframe': chart_tf,
3124
+ }
3125
+ static_contexts[auto_sec_id] = sec_contexts[auto_sec_id]
3126
+ sec_ohlcv_paths[auto_sec_id] = ps
3127
+ self._sec_syminfos[auto_sec_id] = syminfo
3128
+ existing_pairs.add((from_cur, to_cur))
3129
+ existing_pairs.add((to_cur, from_cur))
3130
+
3131
+ def _chart_ohlcv_dir(self) -> 'Path | None':
3132
+ """Return the OHLCV data directory of the chart provider, if any.
3133
+
3134
+ Cross-symbol live :class:`PluginSymbol` entries forward this to the
3135
+ subprocess so the child provider can locate workdir-side resources
3136
+ that live next to the data dir — most notably per-exchange config
3137
+ overrides in ``<workdir>/config/plugins/<provider>.toml`` (e.g. the
3138
+ ``[binance]`` section of ``ccxt.toml``). Without it, the subprocess
3139
+ provider runs with default exchange config while the chart side
3140
+ runs with the override, breaking auth and market-type selection
3141
+ for the cross-symbol feeds.
3142
+ """
3143
+ if self._chart_provider_instance is None:
3144
+ return None
3145
+ ohlcv_path = getattr(self._chart_provider_instance, 'ohlcv_path', None)
3146
+ if ohlcv_path is None:
3147
+ return None
3148
+ return Path(cast('str | Path', ohlcv_path)).parent
3149
+
3150
+ @staticmethod
3151
+ def _ensure_ohlcv_ext(path: str | Path) -> str:
3152
+ """Add the ``.ohlcv`` extension if not already present.
3153
+
3154
+ A dot inside the name belongs to the symbol (e.g. a perpetual
3155
+ ``BTCUSDT.P``), so append by name rather than ``with_suffix`` which
3156
+ would clobber the symbol's own dotted tail.
3157
+ """
3158
+ p = Path(path)
3159
+ if p.name.endswith('.ohlcv'):
3160
+ return str(path)
3161
+ ohlcv_path = p.with_name(p.name + '.ohlcv')
3162
+ if ohlcv_path.exists():
3163
+ return str(ohlcv_path)
3164
+ return str(path)
3165
+
3166
+ def _write_live_strategy_stats(self, position):
3167
+ """Rewrite strategy stats file with current state (live mode, after each bar)."""
3168
+ if self.strat_writer is None:
3169
+ return
3170
+ from .strategy_stats import calculate_strategy_statistics, write_strategy_statistics_csv
3171
+ # noinspection PyBroadException
3172
+ try:
3173
+ self.strat_writer.open()
3174
+ stats = calculate_strategy_statistics(
3175
+ position, self.script.initial_capital,
3176
+ self.equity_curve if self.equity_curve else None,
3177
+ self.first_price, self.last_price,
3178
+ )
3179
+ write_strategy_statistics_csv(stats, self.strat_writer)
3180
+ self.strat_writer.close()
3181
+ except Exception:
3182
+ # noinspection PyBroadException
3183
+ try:
3184
+ self.strat_writer.close()
3185
+ except Exception:
3186
+ pass
3187
+
3188
+ def run(self, on_progress: Callable[[datetime], None] | None = None,
3189
+ on_tick: Callable[[OHLCV], None] | None = None):
3190
+ """
3191
+ Run the script on the data
3192
+
3193
+ :param on_progress: Callback to call on every iteration
3194
+ :param on_tick: Optional callback invoked on every live OHLCV update
3195
+ (intra-bar tick + closed bar). Receives the OHLCV
3196
+ candle. Only fires in live mode, after the historical
3197
+ phase has transitioned. Used by the CLI to render
3198
+ bid/ask in the progress spinner.
3199
+ :raises AssertionError: If the 'main' function does not return a dictionary
3200
+ """
3201
+ for _ in self.run_iter(on_progress=on_progress, on_tick=on_tick):
3202
+ pass