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,2006 @@
1
+ import asyncio
2
+ import os
3
+ import queue
4
+ import signal
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ import sys
9
+ import tomllib
10
+
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from dataclasses import replace as dc_replace
14
+ from datetime import datetime, timedelta, UTC, tzinfo
15
+ from typing import Any
16
+ from zoneinfo import ZoneInfo
17
+
18
+ from typer import Option, Argument, secho, Exit, colors
19
+ from rich.progress import (Progress, SpinnerColumn, TextColumn, BarColumn,
20
+ ProgressColumn, Task, TimeElapsedColumn, TimeRemainingColumn)
21
+ from rich.text import Text
22
+ from rich.console import Console
23
+
24
+ from ..app import app, app_state
25
+ from ..pluggable import PluggableCommand
26
+
27
+ from ...utils.rich.date_column import DateColumn
28
+ from pynecore.core.ohlcv_file import OHLCVReader, OHLCVWriter
29
+ from pynecore.core.data_converter import DataConverter, DataFormatError, ConversionError
30
+ from pynecore.core.aggregator import validate_aggregation
31
+ from pynecore.lib.log import logger as pyne_logger
32
+ from pynecore.lib.timeframe import in_seconds
33
+
34
+ from pynecore.core.broker.exceptions import BrokerManualInterventionError
35
+ from pynecore.lib.log import broker_info, broker_warning
36
+ from pynecore.core.syminfo import SymInfo, mintick_decimals
37
+ from pynecore.core.script_runner import ScriptRunner, DataRequirements, SecurityRequirement
38
+ from pynecore.pynesys.compiler import PyneComp
39
+ from pynecore.core.provider_string import ProviderString, is_provider_string, parse_provider_string
40
+ from pynecore.core.live_runner import live_ohlcv_generator
41
+ from ...cli.utils.api_error_handler import APIErrorHandler
42
+
43
+ __all__ = []
44
+
45
+
46
+ #: Task name given to :func:`_drain_loop_tasks`, used to keep a drain from
47
+ #: cancelling a sibling drain (only possible when the teardown is invoked twice
48
+ #: on a loop whose first drain outran its wait — the drain must never abort
49
+ #: another drain).
50
+ _DRAIN_NAME = "_drain_loop_tasks"
51
+
52
+
53
+ async def _drain_loop_tasks() -> None:
54
+ """Cancel and await every task on the running loop except this one.
55
+
56
+ Runs on the broker event loop itself (scheduled by :func:`_drain_then_stop`).
57
+ Mirrors the standard ``asyncio.runners._cancel_all_tasks`` shutdown: request
58
+ cancellation on all sibling tasks, then await them so each finishes
59
+ unwinding — closing its WebSocket transport and cancelling its own child
60
+ tasks — before the loop is stopped and closed. ``return_exceptions=True``
61
+ keeps a task that re-raises something other than ``CancelledError`` from
62
+ aborting the drain.
63
+ """
64
+ current = asyncio.current_task()
65
+ pending = [
66
+ task for task in asyncio.all_tasks()
67
+ if task is not current and task.get_name() != _DRAIN_NAME
68
+ ]
69
+ if not pending:
70
+ return
71
+ for task in pending:
72
+ task.cancel()
73
+ await asyncio.gather(*pending, return_exceptions=True)
74
+
75
+
76
+ def _drain_then_stop(loop: "Any") -> None:
77
+ """Start the task drain on the loop, then stop the loop once it completes.
78
+
79
+ Runs *on* the loop thread (scheduled with ``call_soon_threadsafe``), which
80
+ is what makes the teardown self-contained: the coroutine is created only at
81
+ the moment a task can be built from it, and ``loop.stop`` fires from the
82
+ drain's completion callback rather than from the caller. A caller that
83
+ stops waiting therefore leaves the teardown running to completion instead
84
+ of stranding a half-scheduled drain — the loop still stops, just later.
85
+
86
+ :param loop: The broker event loop being torn down.
87
+ """
88
+ task = loop.create_task(_drain_loop_tasks(), name=_DRAIN_NAME)
89
+
90
+ def _on_drained(drain: "Any") -> None:
91
+ if not drain.cancelled():
92
+ # Retrieve any exception so it is not reported as never-retrieved.
93
+ _ = drain.exception()
94
+ loop.stop()
95
+
96
+ task.add_done_callback(_on_drained)
97
+
98
+
99
+ def _shutdown_broker_event_loop(
100
+ loop: "Any",
101
+ thread: "threading.Thread | None",
102
+ timeout: float,
103
+ ) -> bool:
104
+ """
105
+ Tear down the broker event-loop pump thread, then close the loop.
106
+
107
+ The loop runs ``run_forever`` on ``thread``; ``loop.stop()`` must be
108
+ scheduled onto the loop thread with ``call_soon_threadsafe`` because the
109
+ caller lives on a different (Pine script) thread. ``loop.close()`` is only
110
+ safe once ``run_forever`` has actually returned — closing a still-running
111
+ loop raises ``RuntimeError: Cannot close a running event loop``. This joins
112
+ the thread first and only closes when the loop has genuinely stopped, so a
113
+ slow-to-drain pump can never crash the CLI exit.
114
+
115
+ :param loop: The broker event loop to stop and close.
116
+ :param thread: The thread running ``loop.run_forever`` (``None`` if the
117
+ pump never started).
118
+ :param timeout: Maximum seconds to wait for the loop thread to exit.
119
+ Non-positive means wait forever, matching the ``--shutdown-timeout``
120
+ contract honoured by :mod:`pynecore.core.live_runner`.
121
+ :return: ``True`` if the loop was closed; ``False`` if it was still running
122
+ after ``timeout`` (left open, not closed, to avoid the
123
+ close-while-running crash).
124
+ """
125
+ # Cancel and await every task still living on the loop BEFORE stopping it.
126
+ # The broker's private order-event stream (``run_event_stream`` →
127
+ # ``watch_orders``) and its WebSocket receive/ping loops are long-lived
128
+ # tasks that the graceful public-data disconnect does not reach. Closing the
129
+ # loop while they are pending destroys them mid-await, producing
130
+ # ``Task was destroyed but it is pending`` warnings and
131
+ # ``RuntimeError: Event loop is closed`` at process exit — the exact failure
132
+ # a strategy exception (which skips the normal completion summary) surfaces.
133
+ # Draining here, while ``run_forever`` is still turning the loop, lets each
134
+ # task observe its cancellation and unwind cleanly (closing its socket)
135
+ # instead of being abandoned. The drain and the subsequent ``loop.stop`` are
136
+ # chained together *on the loop thread* so that giving up on the wait below
137
+ # never strands a partially scheduled teardown.
138
+ join_timeout = timeout if timeout > 0 else None
139
+ try:
140
+ if thread is not None and thread.is_alive() and loop.is_running():
141
+ loop.call_soon_threadsafe(_drain_then_stop, loop)
142
+ else:
143
+ loop.call_soon_threadsafe(loop.stop)
144
+ except RuntimeError:
145
+ # Loop already stopped or closed — nothing to signal.
146
+ pass
147
+ if thread is not None:
148
+ thread.join(timeout=join_timeout)
149
+ # Gate close() on the loop having actually stopped rather than assuming the
150
+ # join succeeded within ``timeout``. A join timeout leaves the thread alive
151
+ # and the loop running; closing it then raises RuntimeError.
152
+ if loop.is_running():
153
+ return False
154
+ loop.close()
155
+ return True
156
+
157
+ console = Console()
158
+
159
+ #: Default seconds between live-run heartbeat lines when the interactive
160
+ #: spinner is suppressed (durable log / non-TTY). Overridable via
161
+ #: ``PYNE_HEARTBEAT_INTERVAL``; ``0`` disables the heartbeat entirely.
162
+ DEFAULT_HEARTBEAT_INTERVAL_S = 30.0
163
+
164
+
165
+ def _resolve_heartbeat_interval(raw: str | None) -> float:
166
+ """Parse the ``PYNE_HEARTBEAT_INTERVAL`` override.
167
+
168
+ :param raw: The raw environment value, if any.
169
+ :return: A positive interval in seconds, or ``0.0`` to disable.
170
+ """
171
+ if raw is None or raw == "":
172
+ return DEFAULT_HEARTBEAT_INTERVAL_S
173
+ try:
174
+ value = float(raw)
175
+ except ValueError:
176
+ return DEFAULT_HEARTBEAT_INTERVAL_S
177
+ return value if value > 0.0 else 0.0
178
+
179
+
180
+ class _LiveHeartbeat:
181
+ """Emit a periodic "still running" line while the live loop is quiet.
182
+
183
+ When the interactive spinner is suppressed (durable log / non-TTY), a
184
+ long silent phase — waiting for the next bar or a resting order to move —
185
+ leaves the transcript with no output for tens of seconds, so an operator
186
+ cannot tell a healthy wait from a hang. This ticker logs a heartbeat on a
187
+ fixed cadence using an :class:`threading.Event` wait (event-driven, no
188
+ busy-poll), so ``stop()`` returns promptly instead of after a full sleep.
189
+ """
190
+
191
+ def __init__(self, interval_s: float, emit: 'Any') -> None:
192
+ self._interval = interval_s
193
+ self._emit = emit
194
+ self._stop = threading.Event()
195
+ self._started_at = time.monotonic()
196
+ self._thread = threading.Thread(
197
+ target=self._run, name="live-heartbeat", daemon=True,
198
+ )
199
+
200
+ def _run(self) -> None:
201
+ while not self._stop.wait(self._interval):
202
+ elapsed = time.monotonic() - self._started_at
203
+ self._emit(elapsed)
204
+
205
+ def start(self) -> None:
206
+ if self._interval > 0.0:
207
+ self._thread.start()
208
+
209
+ def stop(self) -> None:
210
+ self._stop.set()
211
+ if self._thread.is_alive():
212
+ self._thread.join(timeout=1.0)
213
+
214
+
215
+ class CustomTimeElapsedColumn(ProgressColumn):
216
+ """Custom time elapsed column showing tenths of a second."""
217
+
218
+ def render(self, task: Task) -> Text:
219
+ """Render the time elapsed with tenths of a second."""
220
+ elapsed = task.elapsed
221
+ if elapsed is None:
222
+ return Text("--:--:--.-", style="cyan")
223
+
224
+ hours = int(elapsed // 3600)
225
+ minutes = int((elapsed % 3600) // 60)
226
+ seconds = elapsed % 60
227
+
228
+ return Text(f"{hours:02d}:{minutes:02d}:{seconds:04.1f}", style="cyan")
229
+
230
+
231
+ class CustomTimeRemainingColumn(ProgressColumn):
232
+ """Custom time remaining column showing milliseconds."""
233
+
234
+ def render(self, task: Task) -> Text:
235
+ """Render the time remaining with milliseconds."""
236
+ remaining = task.time_remaining
237
+ if remaining is None:
238
+ return Text("--:--.-", style="cyan")
239
+
240
+ minutes = int(remaining // 60)
241
+ seconds = remaining % 60
242
+
243
+ return Text(f"{minutes:02d}:{seconds:06.3f}", style="cyan")
244
+
245
+
246
+ def _exchange_display_time(timestamp: int | float, display_tz: tzinfo) -> datetime:
247
+ """Return an exchange-local naive timestamp for terminal display."""
248
+ return datetime.fromtimestamp(timestamp, UTC).astimezone(display_tz).replace(tzinfo=None)
249
+
250
+
251
+ class ExchangeClockColumn(ProgressColumn):
252
+ """Live exchange clock for the terminal spinner."""
253
+
254
+ def __init__(self, display_tz: tzinfo):
255
+ super().__init__()
256
+ self.display_tz = display_tz
257
+
258
+ def render(self, task: Task) -> Text:
259
+ display_time = _exchange_display_time(time.time(), self.display_tz)
260
+ return Text(f"Live — {display_time:%m-%d %H:%M:%S}", style="white")
261
+
262
+
263
+ def _format_broker_value(value: float, *, signed: bool = False) -> str:
264
+ """Format broker spinner account metrics."""
265
+ if signed:
266
+ return f"{value:+,.2f}"
267
+ return f"{value:,.2f}"
268
+
269
+
270
+ def _select_broker_balance(
271
+ balance: dict[str, float] | None,
272
+ preferred_currency: str | None,
273
+ ) -> tuple[str, float] | None:
274
+ """Pick the balance row to display in the live broker spinner."""
275
+ if not balance:
276
+ return None
277
+ if preferred_currency and preferred_currency in balance:
278
+ return preferred_currency, balance[preferred_currency]
279
+ if len(balance) == 1:
280
+ currency, value = next(iter(balance.items()))
281
+ return currency, value
282
+ currency = sorted(balance)[0]
283
+ return currency, balance[currency]
284
+
285
+
286
+ def _coerce_finite_float(value: Any) -> float | None:
287
+ """Coerce a dynamic position attribute to a finite float.
288
+
289
+ Returns ``None`` when the value is missing, non-numeric, or NaN — so the
290
+ spinner display logic can simply skip it rather than crash the live loop
291
+ on a stray type.
292
+
293
+ :param value: A loosely-typed attribute read via ``getattr``.
294
+ :return: The finite float value, or ``None``.
295
+ """
296
+ if value is None:
297
+ return None
298
+ try:
299
+ result = float(value)
300
+ except (TypeError, ValueError):
301
+ return None
302
+ return result if result == result else None
303
+
304
+
305
+ def _broker_metrics_text(
306
+ position: Any,
307
+ exchange_position: Any,
308
+ balance: dict[str, float] | None,
309
+ preferred_currency: str | None,
310
+ price_decimals: int,
311
+ bid: float | None,
312
+ ask: float | None,
313
+ fallback_price: float | None,
314
+ ) -> str:
315
+ """Return spinner text for broker equity and unrealized PnL."""
316
+ selected_balance = _select_broker_balance(balance, preferred_currency)
317
+ if selected_balance is None:
318
+ return ""
319
+ currency, equity = selected_balance
320
+
321
+ position_text = ""
322
+ unrealized = 0.0
323
+ if exchange_position is not None:
324
+ exchange_side = str(getattr(exchange_position, 'side', '') or '').lower()
325
+ exchange_size = float(getattr(exchange_position, 'size', 0.0) or 0.0)
326
+ if exchange_side == 'short':
327
+ exchange_size = -abs(exchange_size)
328
+ elif exchange_side == 'long':
329
+ exchange_size = abs(exchange_size)
330
+ elif abs(exchange_size) < 1e-12:
331
+ exchange_size = 0.0
332
+
333
+ if abs(exchange_size) > 1e-12:
334
+ position_text = f"Pos [cyan]{exchange_size:g}[/]"
335
+ entry_price = float(getattr(exchange_position, 'entry_price', 0.0) or 0.0)
336
+ if entry_price > 0.0:
337
+ position_text += f" Entry [cyan]{entry_price:.{price_decimals}f}[/]"
338
+ unrealized = float(getattr(exchange_position, 'unrealized_pnl', 0.0) or 0.0)
339
+ elif position is not None:
340
+ position_size = float(getattr(position, 'size', 0.0) or 0.0)
341
+ if abs(position_size) > 1e-12:
342
+ avg_price = _coerce_finite_float(getattr(position, 'avg_price', None))
343
+ position_text = f"Pos [cyan]{position_size:g}[/]"
344
+ if avg_price is not None and avg_price > 0.0:
345
+ position_text += f" Entry [cyan]{avg_price:.{price_decimals}f}[/]"
346
+
347
+ unrealized = float(getattr(position, 'openprofit', 0.0) or 0.0)
348
+ open_trades = list(getattr(position, 'open_trades', []) or [])
349
+ if open_trades:
350
+ unrealized = 0.0
351
+ for trade in open_trades:
352
+ size = float(getattr(trade, 'size', 0.0) or 0.0)
353
+ entry_price = float(getattr(trade, 'entry_price', 0.0) or 0.0)
354
+ mark = bid if size >= 0.0 else ask
355
+ if mark is None:
356
+ mark = fallback_price
357
+ if mark is None:
358
+ continue
359
+ unrealized += (float(mark) - entry_price) * size
360
+
361
+ pnl_style = "green" if unrealized >= 0.0 else "red"
362
+ parts = [f"Eq [cyan]{_format_broker_value(equity)} {currency}[/]"]
363
+ if position_text:
364
+ parts.append(position_text)
365
+ parts.append(f"UPnL [{pnl_style}]{_format_broker_value(unrealized, signed=True)}[/]")
366
+ return " ".join(parts)
367
+
368
+
369
+ def _format_run_completion_summary(
370
+ reason: str,
371
+ position: Any,
372
+ exchange_position: Any,
373
+ balance: dict[str, float] | None,
374
+ preferred_currency: str | None,
375
+ ) -> str:
376
+ """Build the one-line broker-run completion summary.
377
+
378
+ Emitted when a ``--broker`` run stops (graceful shutdown, ``Ctrl-C`` /
379
+ ``SIGTERM`` interrupt, or manual-intervention halt) so the operator gets
380
+ an explicit closing line stating the final position and account equity,
381
+ rather than a transcript that simply ends.
382
+
383
+ :param reason: Short why-it-stopped label (``completed``, ``interrupted``…).
384
+ :param position: The Pine ``position`` object (paper/fallback size source).
385
+ :param exchange_position: The broker position snapshot, when available.
386
+ :param balance: The account equity mapping (currency -> equity).
387
+ :param preferred_currency: ``syminfo.currency`` used to pick the equity row.
388
+ :return: A single log line summarizing the final state.
389
+ """
390
+ parts = [f"run stopped ({reason})"]
391
+
392
+ size: float | None = None
393
+ if exchange_position is not None:
394
+ raw = _coerce_finite_float(getattr(exchange_position, 'size', None))
395
+ if raw is not None:
396
+ side = str(getattr(exchange_position, 'side', '') or '').lower()
397
+ if side == 'short':
398
+ raw = -abs(raw)
399
+ elif side == 'long':
400
+ raw = abs(raw)
401
+ size = raw
402
+ elif position is not None:
403
+ size = _coerce_finite_float(getattr(position, 'size', None))
404
+
405
+ if size is not None:
406
+ if abs(size) < 1e-12:
407
+ parts.append("position=flat")
408
+ else:
409
+ parts.append(f"position={size:g}")
410
+
411
+ selected = _select_broker_balance(balance, preferred_currency)
412
+ if selected is not None:
413
+ currency, equity = selected
414
+ parts.append(f"equity={_format_broker_value(equity)} {currency}")
415
+
416
+ return " ".join(parts)
417
+
418
+
419
+ def _parse_time_value(value: str | None, *, allow_bars: bool = False) -> datetime | int | None:
420
+ """
421
+ Parse a --from or --to parameter value.
422
+
423
+ :param value: The raw string value.
424
+ :param allow_bars: If True, allow negative numbers as bar counts.
425
+ :return: A datetime, a negative int (bar count), or None.
426
+ """
427
+ if value is None:
428
+ return None
429
+ value: str = value.strip()
430
+
431
+ # Negative number = bar count (only for --from in provider mode)
432
+ if allow_bars and value.startswith('-'):
433
+ try:
434
+ bars = int(value)
435
+ return bars
436
+ except ValueError:
437
+ pass
438
+
439
+ # Positive number = days back
440
+ try:
441
+ days = int(value)
442
+ if days < 0:
443
+ secho("Error: Days cannot be negative (use negative numbers only with provider mode for bar count)",
444
+ err=True, fg=colors.RED)
445
+ raise Exit(1)
446
+ return (datetime.now(UTC) - timedelta(days=days)).replace(second=0, microsecond=0)
447
+ except ValueError:
448
+ pass
449
+
450
+ # Date string
451
+ try:
452
+ return datetime.fromisoformat(value)
453
+ except ValueError:
454
+ secho(f"Error: Invalid date or number: '{value}'", err=True, fg=colors.RED)
455
+ raise Exit(1)
456
+
457
+
458
+ class _ProviderData:
459
+ """Result of provider data download, including the provider instance for live mode."""
460
+
461
+ def __init__(self, ohlcv_path: Path, syminfo: 'SymInfo', parsed_string: ProviderString,
462
+ provider_instance=None, time_from_ts: int | None = None):
463
+ self.ohlcv_path = ohlcv_path
464
+ self.syminfo = syminfo
465
+ self.provider_instance = provider_instance
466
+ self.parsed_string: ProviderString = parsed_string
467
+ # Exact start timestamp that yields exactly the requested bar
468
+ # count in ``-N bars`` mode — None when the caller should use
469
+ # the file's natural start (date/days mode, no bar target).
470
+ self.time_from_ts = time_from_ts
471
+
472
+
473
+ def _missing_slots(real_ts: list[int], start_ts: int, end_ts: int,
474
+ tf_seconds: int) -> list[int]:
475
+ """Return the aligned slot start-timestamps in ``[start_ts, end_ts)`` that
476
+ have no real bar.
477
+
478
+ The expected grid is inferred from the real bars themselves — anchored on
479
+ the newest real bar and stepped back by ``tf_seconds`` — so the reported
480
+ slots share the feed's phase and only genuinely-absent slots surface. With
481
+ no real bars the grid phase is unknown and an empty list is returned.
482
+
483
+ :param real_ts: Real (non-gap-fill) bar timestamps; need not be sorted.
484
+ :param start_ts: Inclusive window start (epoch seconds).
485
+ :param end_ts: Exclusive window end (epoch seconds).
486
+ :param tf_seconds: Timeframe length in seconds.
487
+ :return: Sorted list of missing slot start-timestamps.
488
+ """
489
+ if tf_seconds <= 0 or not real_ts:
490
+ return []
491
+ present = set(real_ts)
492
+ anchor = max(real_ts)
493
+ missing: list[int] = []
494
+ slot = anchor
495
+ # Walk the grid back from the newest real bar to ``start_ts``.
496
+ while slot >= start_ts:
497
+ if start_ts <= slot < end_ts and slot not in present:
498
+ missing.append(slot)
499
+ slot -= tf_seconds
500
+ missing.reverse()
501
+ return missing
502
+
503
+
504
+ def _classify_missing_slots(missing: list[int], syminfo: 'SymInfo',
505
+ tf_seconds: int) -> tuple[list[int], list[int]]:
506
+ """Split missing slots into in-session anomalies vs closed-session gaps.
507
+
508
+ A slot that falls inside a trading session but has no bar is an *anomaly*
509
+ (incomplete pagination or a venue-omitted tick), while a slot in a
510
+ known-closed window (weekend, out-of-hours) is an *expected* venue gap.
511
+ When the symbol carries no ``opening_hours`` (24/7 instrument) every slot
512
+ is treated as in-session, so any hole is reported as an anomaly.
513
+
514
+ :param missing: Missing slot start-timestamps (epoch seconds).
515
+ :param syminfo: Symbol calendar (``opening_hours``, ``timezone``).
516
+ :param tf_seconds: Timeframe length in seconds.
517
+ :return: ``(in_session, closed)`` timestamp lists.
518
+ """
519
+ opening_hours = getattr(syminfo, 'opening_hours', None)
520
+ if not opening_hours:
521
+ return list(missing), []
522
+ from pynecore.lib.session import _is_in_session
523
+ try:
524
+ tz = ZoneInfo(syminfo.timezone)
525
+ except Exception: # noqa: BLE001
526
+ return list(missing), []
527
+ in_session: list[int] = []
528
+ closed: list[int] = []
529
+ for ts in missing:
530
+ local_dt = datetime.fromtimestamp(ts, tz)
531
+ if _is_in_session(opening_hours, local_dt, tf_seconds):
532
+ in_session.append(ts)
533
+ else:
534
+ closed.append(ts)
535
+ return in_session, closed
536
+
537
+
538
+ def _merge_intervals(slots: list[int], tf_seconds: int) -> list[tuple[int, int]]:
539
+ """Coalesce consecutive grid slots into ``[start, end)`` epoch intervals.
540
+
541
+ :param slots: Sorted slot start-timestamps (epoch seconds).
542
+ :param tf_seconds: Timeframe length in seconds.
543
+ :return: List of ``(start_ts, end_ts)`` half-open intervals.
544
+ """
545
+ if not slots:
546
+ return []
547
+ intervals: list[tuple[int, int]] = []
548
+ run_start = slots[0]
549
+ prev = slots[0]
550
+ for ts in slots[1:]:
551
+ if ts == prev + tf_seconds:
552
+ prev = ts
553
+ continue
554
+ intervals.append((run_start, prev + tf_seconds))
555
+ run_start = ts
556
+ prev = ts
557
+ intervals.append((run_start, prev + tf_seconds))
558
+ return intervals
559
+
560
+
561
+ def _format_missing_report(bar_count: int, real_bars: int, oldest_ts: int | None,
562
+ in_session: list[int], closed: list[int],
563
+ tf_seconds: int, horizon_reached: bool) -> str:
564
+ """Build a precise, actionable coverage-shortfall message.
565
+
566
+ Distinguishes a venue history-horizon limit (extending ``from`` yields no
567
+ older bars) from in-session holes (incomplete pagination / omitted ticks),
568
+ and lists the exact missing intervals for each class instead of a vague
569
+ sparse-coverage count.
570
+ """
571
+ def fmt(ts: int) -> str:
572
+ return datetime.fromtimestamp(ts, UTC).strftime('%Y-%m-%d %H:%M')
573
+
574
+ def fmt_intervals(slots: list[int]) -> str:
575
+ parts = [f"{fmt(a)}..{fmt(b)}Z" for a, b in _merge_intervals(slots, tf_seconds)]
576
+ return ', '.join(parts)
577
+
578
+ lines = [f"Warning: requested {bar_count} bars, got {real_bars} real bars."]
579
+ if horizon_reached and oldest_ts is not None:
580
+ lines.append(
581
+ f" Venue history horizon reached: oldest bar the feed serves is "
582
+ f"{fmt(oldest_ts)}Z. The remaining {bar_count - real_bars} bar(s) "
583
+ f"predate the venue's available history (not a pagination gap)."
584
+ )
585
+ if in_session:
586
+ lines.append(
587
+ f" {len(in_session)} in-session slot(s) missing (incomplete "
588
+ f"pagination or venue-omitted ticks): {fmt_intervals(in_session)}"
589
+ )
590
+ if closed:
591
+ lines.append(
592
+ f" {len(closed)} slot(s) fall in known-closed sessions "
593
+ f"(expected venue gap): {fmt_intervals(closed)}"
594
+ )
595
+ if not horizon_reached and not in_session and not closed:
596
+ lines.append(" Cause could not be localized from the downloaded range.")
597
+ return '\n'.join(lines)
598
+
599
+
600
+ try:
601
+ import fcntl as _fcntl
602
+ except ImportError: # pragma: no cover - non-POSIX (Windows) fallback
603
+ _fcntl = None # type: ignore[assignment]
604
+
605
+
606
+ @contextmanager
607
+ def _ohlcv_download_lock(ohlcv_path: Path | None):
608
+ """Serialize the shared-OHLCV download/rewrite across concurrent processes.
609
+
610
+ Two runs on the same ``(provider, symbol, timeframe)`` share one
611
+ ``.ohlcv`` file. The warmup download ``seek(0)``-truncates and rewrites it;
612
+ a second process reading (or truncating) that file at the same instant sees
613
+ a half-written or empty file. An OS advisory lock on a sidecar ``.lock``
614
+ next to the ``.ohlcv`` makes the second process block in the kernel until
615
+ the first finishes, then read a complete file.
616
+
617
+ The wait is an ``fcntl.flock`` kernel wait — event-driven, not a poll loop
618
+ or a sleep. When ``fcntl`` is unavailable (non-POSIX) or the path is unknown
619
+ the lock degrades to a no-op: correctness on the single-run path is
620
+ unaffected and the bot must never halt on a missing OS primitive.
621
+
622
+ :param ohlcv_path: The target ``.ohlcv`` path, or ``None`` (live/in-memory
623
+ feeds with no shared file — nothing to serialize).
624
+ """
625
+ if ohlcv_path is None or _fcntl is None:
626
+ yield
627
+ return
628
+
629
+ lock_path = ohlcv_path.with_suffix(ohlcv_path.suffix + ".lock")
630
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
631
+ lock_file = open(lock_path, "w")
632
+ try:
633
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_EX)
634
+ try:
635
+ yield
636
+ finally:
637
+ _fcntl.flock(lock_file.fileno(), _fcntl.LOCK_UN)
638
+ finally:
639
+ lock_file.close()
640
+
641
+
642
+ @contextmanager
643
+ def _atomic_ohlcv_download_target(provider: Any):
644
+ """Write one provider download privately, then atomically publish it.
645
+
646
+ The sidecar lock serializes publishers, while the temporary file keeps the
647
+ canonical path complete for readers during the entire rewrite. The
648
+ provider's original path and unopened writer are restored before return.
649
+ """
650
+ final_path = provider.ohlcv_path
651
+ if final_path is None:
652
+ yield
653
+ return
654
+ final_path.parent.mkdir(parents=True, exist_ok=True)
655
+ fd, raw_temp_path = tempfile.mkstemp(
656
+ prefix=f".{final_path.name}.", suffix=".tmp", dir=final_path.parent,
657
+ )
658
+ os.close(fd)
659
+ temp_path = Path(raw_temp_path)
660
+ original_writer = provider.ohlcv_file
661
+ try:
662
+ with _ohlcv_download_lock(final_path):
663
+ provider.ohlcv_path = temp_path
664
+ provider.ohlcv_file = OHLCVWriter(temp_path)
665
+ try:
666
+ yield
667
+ os.replace(temp_path, final_path)
668
+ finally:
669
+ provider.ohlcv_path = final_path
670
+ provider.ohlcv_file = original_writer
671
+ finally:
672
+ temp_path.unlink(missing_ok=True)
673
+
674
+
675
+ def _download_provider_data(provider_str: str, time_from_str: str | None) -> _ProviderData:
676
+ """
677
+ Download historical data from a provider and return the result.
678
+
679
+ :param provider_str: Provider string (e.g. "ccxt:BYBIT:BTC/USDT:USDT@1D").
680
+ :param time_from_str: The --from parameter value (date, days, or -bars).
681
+ :return: _ProviderData with ohlcv_path, syminfo, and provider instance.
682
+ """
683
+ from pynecore.core.plugin import load_plugin, ProviderPlugin
684
+ from pynecore.core.config import ensure_config
685
+ from pynecore.lib.timeframe import in_seconds
686
+
687
+ # Load the provider plugin first so we know whether it is multi-broker,
688
+ # which controls how the provider string is split (broker vs. symbol).
689
+ provider_name = provider_str.split(':', 1)[0].lower()
690
+ provider_class = load_plugin(provider_name)
691
+ if not issubclass(provider_class, ProviderPlugin):
692
+ secho(f"Plugin '{provider_name}' is not a data provider.", err=True, fg=colors.RED)
693
+ raise Exit(1)
694
+
695
+ ps = parse_provider_string(provider_str, require_timeframe=True,
696
+ multi_broker=provider_class.multi_broker)
697
+ # Store the normalized (lowercased) provider name so that later
698
+ # case-sensitive ``load_plugin()`` lookups for security/auto-rate
699
+ # contexts succeed even when the user typed the provider in a
700
+ # different case (e.g. ``CCXT:...``).
701
+ ps = dc_replace(ps, provider=provider_name)
702
+
703
+ # Default to -500 bars if --from not specified in provider mode
704
+ if not time_from_str:
705
+ time_from_str = "-500"
706
+
707
+ time_from_value = _parse_time_value(time_from_str, allow_bars=True)
708
+ time_to_dt = datetime.now(UTC).replace(second=0, microsecond=0)
709
+
710
+ # Convert bar count to time range. ``bar_count`` being set signals
711
+ # the "-N bars" mode — we then guarantee at least N *real* bars
712
+ # (exchange-provided, non-gap-fill) after download, extending the
713
+ # from-timestamp on miss. Date/days ranges are left untouched.
714
+ tf_seconds = in_seconds(ps.timeframe)
715
+ bar_count: int | None = None
716
+ if isinstance(time_from_value, int) and time_from_value < 0:
717
+ bc = abs(time_from_value)
718
+ bar_count = bc
719
+ # Pad the request by one bar to absorb the still-forming current
720
+ # bar that closed-bars-only providers (e.g. Capital.com) filter
721
+ # out of history responses. Without this, every ``-N`` run
722
+ # against a now-aligned end-time would burn a wasted retry pass
723
+ # (``real_bars == N - 1`` on first attempt → retry → success).
724
+ time_from_dt = time_to_dt - timedelta(seconds=tf_seconds * (bc + 1))
725
+ else:
726
+ assert isinstance(time_from_value, datetime)
727
+ time_from_dt = time_from_value
728
+
729
+ # Load config
730
+ config = None
731
+ config_cls: type | None = getattr(provider_class, 'Config', None)
732
+ if config_cls is not None:
733
+ config = ensure_config(config_cls,
734
+ app_state.config_dir / 'plugins' / f'{provider_name}.toml')
735
+
736
+ # Create provider instance. ``provider_symbol`` re-folds the broker into
737
+ # the symbol for multi-broker providers, which split it off internally.
738
+ provider_instance: ProviderPlugin = provider_class(
739
+ symbol=ps.provider_symbol, timeframe=ps.timeframe,
740
+ ohlcv_dir=app_state.data_dir, config=config
741
+ )
742
+
743
+ # Fetch symbol info
744
+ with Progress(SpinnerColumn(finished_text="[green]✓"), TextColumn("{task.description}")) as progress:
745
+ task = progress.add_task("Fetching symbol info...", total=1)
746
+ syminfo = provider_instance.get_symbol_info(force_update=not provider_instance.is_symbol_info_exists())
747
+ progress.update(task, completed=1)
748
+
749
+ # Download OHLCV data (always fresh in provider mode). In bar-count
750
+ # mode we may re-download with an extended ``from`` until we hit the
751
+ # target — some feeds omit minutes with no ticks (CFD quiet hours,
752
+ # illiquid futures), and ``--from -500`` must mean 500 real bars.
753
+ # The Progress wrapper lives outside the retry loop so a gap-driven
754
+ # second pass updates the same spinner instead of stamping a
755
+ # duplicate ``Downloading OHLCV data...`` line.
756
+ max_retries = 4
757
+ with Progress(
758
+ SpinnerColumn(finished_text="[green]✓"),
759
+ TextColumn("{task.description}"),
760
+ DateColumn(),
761
+ BarColumn(),
762
+ TimeElapsedColumn(),
763
+ "/",
764
+ TimeRemainingColumn(),
765
+ ) as progress:
766
+ task = progress.add_task(
767
+ "Downloading OHLCV data...", total=1, start_time=time_from_dt,
768
+ )
769
+ # Oldest real bar the venue served on the previous attempt. When a
770
+ # further-extended ``from`` fails to pull any older bar, the feed's
771
+ # history horizon is reached and extending again is pointless — we
772
+ # stop and surface that precise reason instead of burning retries.
773
+ prev_oldest_ts: int | None = None
774
+ # Serialize the truncate-and-rewrite (and the verification reads) against
775
+ # any concurrent run sharing this ``.ohlcv`` file: a second process waits
776
+ # on the kernel flock and then reads a complete file, never a
777
+ # half-truncated one. The lock spans the whole retry loop and the
778
+ # bar-count pin below so no other process can rewrite the file between
779
+ # our download and our reads.
780
+ exact_from_ts: int | None = None
781
+ with _atomic_ohlcv_download_target(provider_instance):
782
+ for attempt in range(max_retries + 1):
783
+ with provider_instance as ohlcv_writer:
784
+ ohlcv_writer.seek(0)
785
+ ohlcv_writer.truncate()
786
+
787
+ time_from_dl = time_from_dt.replace(tzinfo=None) if time_from_dt.tzinfo else time_from_dt
788
+ time_to_dl = time_to_dt.replace(tzinfo=None) if time_to_dt.tzinfo else time_to_dt
789
+
790
+ total_seconds = int((time_to_dl - time_from_dl).total_seconds())
791
+
792
+ progress.update(
793
+ task, total=total_seconds, completed=0,
794
+ start_time=time_from_dl,
795
+ )
796
+
797
+ def cb_progress(current_time: datetime):
798
+ elapsed_seconds = int((current_time - time_from_dl).total_seconds())
799
+ progress.update(task, completed=elapsed_seconds)
800
+
801
+ provider_instance.download_ohlcv(time_from_dl, time_to_dl, on_progress=cb_progress)
802
+
803
+ if bar_count is None:
804
+ break
805
+
806
+ # Collect real (``volume >= 0``) bar timestamps in the requested
807
+ # range — gap-fill rows (``volume == -1``) emitted by the OHLCV
808
+ # writer don't count against the target.
809
+ window_from_ts = int(time_from_dt.timestamp())
810
+ window_to_ts = int(time_to_dt.timestamp())
811
+ with OHLCVReader(provider_instance.ohlcv_path) as r: # type: ignore[arg-type]
812
+ real_ts = [b.timestamp for b in r.read_from(
813
+ window_from_ts, window_to_ts, skip_gaps=True,
814
+ )]
815
+ real_bars = len(real_ts)
816
+ oldest_ts = min(real_ts) if real_ts else None
817
+
818
+ if real_bars >= bar_count:
819
+ break
820
+
821
+ # History-horizon check: if extending ``from`` earlier did not
822
+ # surface any older bar than the previous attempt, the venue has
823
+ # no more history — retrying cannot help, so report precisely now.
824
+ horizon_reached = (
825
+ prev_oldest_ts is not None and oldest_ts is not None
826
+ and oldest_ts >= prev_oldest_ts
827
+ )
828
+
829
+ if horizon_reached or attempt == max_retries:
830
+ missing_slots = _missing_slots(
831
+ real_ts, window_from_ts, window_to_ts, int(tf_seconds),
832
+ )
833
+ in_session, closed = _classify_missing_slots(
834
+ missing_slots, syminfo, int(tf_seconds),
835
+ )
836
+ secho(
837
+ _format_missing_report(
838
+ bar_count, real_bars, oldest_ts,
839
+ in_session, closed, int(tf_seconds), horizon_reached,
840
+ ),
841
+ fg=colors.YELLOW,
842
+ )
843
+ break
844
+
845
+ prev_oldest_ts = oldest_ts
846
+
847
+ # Extend the range, anchoring on the oldest bar actually served so
848
+ # the next pass reaches strictly-older history: request the missing
849
+ # count of slots before it, plus a multi-day buffer that clears any
850
+ # single weekend / session gap in one jump. Without the buffer a
851
+ # weekend between ``from`` and the oldest bar would stall the
852
+ # oldest cursor and be misread as the venue history horizon. The
853
+ # over-fetch is harmless — the range is pinned to exactly N real
854
+ # bars below.
855
+ anchor_ts = oldest_ts if oldest_ts is not None else window_from_ts
856
+ missing = bar_count - real_bars
857
+ candidate = (
858
+ datetime.fromtimestamp(anchor_ts, UTC).replace(tzinfo=None)
859
+ - timedelta(seconds=tf_seconds * (missing + 10))
860
+ - timedelta(days=3)
861
+ )
862
+ if time_from_dt.tzinfo is not None:
863
+ candidate = candidate.replace(tzinfo=UTC)
864
+ # Only ever move the window start earlier.
865
+ time_from_dt = min(time_from_dt, candidate)
866
+
867
+ assert provider_instance.ohlcv_path is not None
868
+
869
+ # For bar-count mode, pin the start timestamp to the N-th last real
870
+ # bar so the reader serves *exactly* N bars, not the over-fetched
871
+ # surplus we used to guarantee coverage through gaps.
872
+ if bar_count is not None:
873
+ with OHLCVReader(provider_instance.ohlcv_path) as r: # type: ignore[arg-type]
874
+ real_ts = [b.timestamp for b in r.read_from(
875
+ 0, int(time_to_dt.timestamp()), skip_gaps=True,
876
+ )]
877
+ if len(real_ts) >= bar_count:
878
+ exact_from_ts = real_ts[-bar_count]
879
+
880
+ return _ProviderData(
881
+ ohlcv_path=provider_instance.ohlcv_path,
882
+ syminfo=syminfo,
883
+ provider_instance=provider_instance,
884
+ parsed_string=ps,
885
+ time_from_ts=exact_from_ts,
886
+ )
887
+
888
+
889
+ #: Startup provider-download retry backoff (seconds): the first wait and the
890
+ #: ceiling the exponential growth saturates at. Internal tunables — deliberately
891
+ #: not user config: a live bot should keep trying until the broker returns, and
892
+ #: ~1 minute is a sane poll cadence through a multi-hour maintenance window.
893
+ _PROVIDER_RETRY_BASE_DELAY = 2.0
894
+ _PROVIDER_RETRY_MAX_DELAY = 60.0
895
+
896
+
897
+ def _wait_before_retry(delay: float) -> None:
898
+ """Block for ``delay`` seconds interruptibly, without a bare sleep.
899
+
900
+ Waits on a never-set :class:`threading.Event`, an event-driven,
901
+ deadline-bounded primitive: a ``Ctrl-C`` (``KeyboardInterrupt`` in the main
902
+ thread) breaks out of the backoff at once instead of stalling for the full
903
+ delay, and there is no busy-poll.
904
+
905
+ :param delay: Maximum seconds to wait.
906
+ """
907
+ threading.Event().wait(timeout=delay)
908
+
909
+
910
+ def _download_provider_data_resilient(
911
+ provider_str: str, time_from_str: str | None, *, retry_transient: bool,
912
+ ) -> _ProviderData:
913
+ """Download provider data, riding out transient broker outages.
914
+
915
+ For a long-running run (``--broker`` / ``--live``) a *transient* provider
916
+ failure — broker maintenance, a lost route, a dropped connection — must not
917
+ strand the bot: we wait with capped exponential backoff and keep retrying
918
+ until the feed returns or the operator interrupts (``Ctrl-C``). A
919
+ *permanent* failure (unknown symbol, bad credentials, wrong account mode),
920
+ and every failure of a one-shot backtest, still exits immediately with a
921
+ clean one-line message instead of a traceback — mirroring ``pyne data
922
+ download``.
923
+
924
+ Retrying is intentionally unbounded for the transient class: a bot is meant
925
+ to run until told to stop, so giving up after N attempts would re-introduce
926
+ the very crash we are fixing for any outage longer than the backoff budget.
927
+ The safeguard against looping on a misclassified error is that the transient
928
+ set is narrow and source-specific (see ``is_retryable_provider_error`` and
929
+ the cTrader ``_CONNECTION_CLASS_CODES``), and every wait is logged.
930
+
931
+ :param provider_str: The provider string (e.g. ``ctrader:pepperstoneuk:BTCUSD@1``).
932
+ :param time_from_str: The ``--from`` value (date, days, or ``-bars``).
933
+ :param retry_transient: Whether to wait-and-retry on transient errors —
934
+ ``True`` only for long-running ``--broker`` / ``--live`` runs.
935
+ :return: The downloaded provider data.
936
+ """
937
+ from pynecore.core.plugin import ProviderError, is_retryable_provider_error
938
+
939
+ delay = _PROVIDER_RETRY_BASE_DELAY
940
+ attempt = 0
941
+ while True:
942
+ try:
943
+ return _download_provider_data(provider_str, time_from_str)
944
+ except ProviderError as e:
945
+ if not (retry_transient and is_retryable_provider_error(e)):
946
+ secho(f"Error: {e}", err=True, fg=colors.RED)
947
+ raise Exit(1)
948
+ attempt += 1
949
+ secho(
950
+ f"Provider temporarily unavailable ({e}); waiting {int(delay)}s "
951
+ f"before retry #{attempt} (press Ctrl-C to abort)...",
952
+ err=True, fg=colors.YELLOW,
953
+ )
954
+ _wait_before_retry(delay)
955
+ delay = min(delay * 2.0, _PROVIDER_RETRY_MAX_DELAY)
956
+
957
+
958
+ def _print_data_requirements(requirements: DataRequirements, script_name: str) -> None:
959
+ """Print a plain, human-readable summary of a script's data dependencies.
960
+
961
+ Used by ``--list-data``: only non-empty sections are shown, with ASCII
962
+ markers (``->``, ``!``) so the output stays terminal-safe.
963
+
964
+ :param requirements: The classified buckets from
965
+ :meth:`ScriptRunner.list_data_requirements`.
966
+ :param script_name: Display name of the script (for the header line).
967
+ """
968
+ def _tags(sr: SecurityRequirement) -> str:
969
+ out = ""
970
+ if sr.is_ltf:
971
+ out += " (lower timeframe)"
972
+ if sr.ignore_invalid_symbol:
973
+ out += " [ignore_invalid_symbol]"
974
+ if sr.from_library:
975
+ out += " (from library)"
976
+ return out
977
+
978
+ lines: list[str] = [
979
+ f"Data requirements for {script_name} "
980
+ f"(chart: {requirements.chart_symbol} @ {requirements.chart_tf})"
981
+ ]
982
+
983
+ total = (len(requirements.chart_main) + len(requirements.same_symbol_other_tf)
984
+ + len(requirements.cross_symbol) + len(requirements.dynamic))
985
+ if total == 0:
986
+ lines.append("")
987
+ lines.append("No external data required — this script uses only the "
988
+ "chart data you pass as DATA.")
989
+ console.print("\n".join(lines), markup=False, highlight=False)
990
+ return
991
+
992
+ if requirements.chart_main:
993
+ lines.append("")
994
+ lines.append("Chart / main data (served from the DATA you pass):")
995
+ for r in requirements.chart_main:
996
+ lines.append(f" -> {r.symbol} @ {r.timeframe}{_tags(r)}")
997
+
998
+ if requirements.same_symbol_other_tf:
999
+ lines.append("")
1000
+ lines.append("Same symbol, other timeframe (resampled from the chart base data):")
1001
+ for r in requirements.same_symbol_other_tf:
1002
+ suffix = (" [mapping present]" if r.has_security_mapping
1003
+ else f" [needs --security '{r.timeframe}=<base file>' in backtest]")
1004
+ lines.append(f" -> {r.symbol} @ {r.timeframe}{_tags(r)}{suffix}")
1005
+
1006
+ if requirements.cross_symbol:
1007
+ lines.append("")
1008
+ lines.append("Cross-symbol data (separate download / symbol_map / --security required):")
1009
+ for r in requirements.cross_symbol:
1010
+ if r.has_security_mapping:
1011
+ suffix = " [--security mapping present]"
1012
+ elif r.has_global_map:
1013
+ status = "ok" if r.mapped_file_exists else "missing"
1014
+ fname = Path(r.mapped_file).name if r.mapped_file else "?"
1015
+ suffix = (f" [symbol_map -> {r.mapped_provider}:"
1016
+ f"{r.mapped_native_symbol} -> {fname} ({status})]")
1017
+ else:
1018
+ suffix = " ! no mapping"
1019
+ lines.append(f" -> {r.symbol} @ {r.timeframe}{_tags(r)}{suffix}")
1020
+ if r.has_global_map and not r.mapped_file_exists and r.download_suggestion:
1021
+ lines.append(f" download: {r.download_suggestion}")
1022
+ elif not r.has_global_map and r.file_suggestions:
1023
+ lines.append(f" existing data matching ticker: "
1024
+ f"{', '.join(r.file_suggestions)}")
1025
+
1026
+ if requirements.dynamic:
1027
+ lines.append("")
1028
+ lines.append("Dynamic data (symbol or timeframe only known at runtime):")
1029
+ lines.append(f" ! {len(requirements.dynamic)} request.security() "
1030
+ f"call(s) with a runtime symbol/timeframe")
1031
+ lines.append(" -> cannot be listed statically; inspect the script source")
1032
+
1033
+ console.print("\n".join(lines), markup=False, highlight=False)
1034
+
1035
+
1036
+ @app.command(cls=PluggableCommand)
1037
+ def run(
1038
+ script: Path = Argument(..., dir_okay=False, file_okay=True, help="Script to run (.py or .pine)"),
1039
+ data: str = Argument(...,
1040
+ help="Data file (*.ohlcv, *.csv) or provider string "
1041
+ "(e.g. ccxt:BYBIT:BTC/USDT:USDT@1D)"),
1042
+ time_from: str | None = Option(None, '--from', '-f',
1043
+ metavar="[DATE|DAYS|-BARS]",
1044
+ help="Start: date (2025-01-01), days back (30), "
1045
+ "or -N bars back (-500). Default: -500 bars in provider mode."),
1046
+ time_to: str | None = Option(None, '--to', '-t',
1047
+ metavar="[DATE|DAYS]",
1048
+ help="End: date or days from start (default: end of data or now)"),
1049
+ plot_path: Path | None = Option(None, "--plot", "-pp",
1050
+ help="Path to save the plot data",
1051
+ rich_help_panel="Out Path Options"),
1052
+ strat_path: Path | None = Option(None, "--strat", "-sp",
1053
+ help="Path to save the strategy statistics",
1054
+ rich_help_panel="Out Path Options"
1055
+ ),
1056
+ trade_path: Path | None = Option(None, "--trade", "-tp",
1057
+ help="Path to save the trade data",
1058
+ rich_help_panel="Out Path Options"),
1059
+ viz: bool = Option(False, "--viz", "-vz",
1060
+ help="Write plot/drawing visual data (NDJSON)",
1061
+ rich_help_panel="Out Path Options"),
1062
+ viz_path: Path | None = Option(None, "--viz-path",
1063
+ help="Viz NDJSON path (implies --viz)",
1064
+ rich_help_panel="Out Path Options"),
1065
+ viz_journal: bool = Option(False, "--viz-journal",
1066
+ help="Record per-bar drawing create/update/delete events "
1067
+ "(implies --viz)",
1068
+ rich_help_panel="Out Path Options"),
1069
+ api_key: str | None = Option(None, "--api-key", "-a",
1070
+ help="PyneSys API key for compilation (overrides configuration file)",
1071
+ envvar="PYNESYS_API_KEY",
1072
+ rich_help_panel="Compilation Options"),
1073
+ live: bool = Option(False, "--live", "-l",
1074
+ help="Continue with live data after historical phase "
1075
+ "(provider mode only)"),
1076
+ broker: bool = Option(False, "--broker",
1077
+ help="Enable live broker trading — requires a provider plugin that "
1078
+ "subclasses BrokerPlugin. Implies --live.",
1079
+ rich_help_panel="Live Options"),
1080
+ run_label: str | None = Option(None, "--run-label",
1081
+ help="Optional label to distinguish parallel instances of the "
1082
+ "same strategy+account+symbol+timeframe. Stored in the "
1083
+ "broker run_id as ``...#<label>``.",
1084
+ rich_help_panel="Live Options"),
1085
+ shutdown_timeout: float = Option(120.0, "--shutdown-timeout",
1086
+ help="Max seconds to wait for graceful shutdown "
1087
+ "(0 = wait forever)",
1088
+ rich_help_panel="Live Options"),
1089
+ no_log_ohlcv: bool = Option(False, "--no-log-ohlcv",
1090
+ help="Disable per-bar OHLCV log lines in live mode "
1091
+ "(default: enabled).",
1092
+ rich_help_panel="Live Options"),
1093
+ security: list[str] | None = Option(None, "--security", "-sec",
1094
+ help='Security data: "TIMEFRAME=data_name" or '
1095
+ '"SYMBOL:TIMEFRAME=data_name"',
1096
+ rich_help_panel="Security Options"),
1097
+ list_data: bool = Option(False, "--list-data",
1098
+ help="List the OHLCV data this script needs (from "
1099
+ "request.security calls) and exit without running.",
1100
+ rich_help_panel="Security Options"),
1101
+ timeframe: str | None = Option(None, "--timeframe", "-tf",
1102
+ help="Chart timeframe (TradingView format, e.g. '60', '1D'). "
1103
+ "When larger than data timeframe: aggregates on-the-fly, "
1104
+ "or activates bar magnifier if strategy uses "
1105
+ "use_bar_magnifier=true."),
1106
+
1107
+ ):
1108
+ """
1109
+ Run a script (.py or .pine)
1110
+
1111
+ The system automatically searches for the workdir folder in the current and parent directories.
1112
+ If not found, it creates or uses a workdir folder in the current directory.
1113
+
1114
+ If [bold]script[/] path is a name without full path, it will be searched in the [italic]"workdir/scripts"[/] directory.
1115
+ Similarly, if [bold]data[/] path is a name without full path, it will be searched in the [italic]"workdir/data"[/] directory.
1116
+ The [bold]plot_path[/], [bold]strat_path[/], and [bold]trade_path[/] work the same way - if they are names without full paths,
1117
+ they will be saved in the [italic]"workdir/output"[/] directory.
1118
+
1119
+ [bold]Data Source:[/bold]
1120
+ The [bold]data[/] argument accepts either a file path or a provider string:
1121
+ \b
1122
+ File mode: pyne run script.py data.csv
1123
+ Provider mode: pyne run script.py ccxt:BYBIT:BTC/USDT:USDT@1D -f -500
1124
+
1125
+ In provider mode, historical data is downloaded automatically. The --from/-f parameter
1126
+ accepts: date (2025-01-01), days back (30), or -N bars back (-500). Default: -500 bars.
1127
+
1128
+ [bold]Pine Script Support:[/bold]
1129
+ Pine Script (.pine) files are automatically compiled to Python (.py) before execution.
1130
+ A valid [bold]PyneSys API[/bold] key is required. Get one at [blue]https://pynesys.io[/blue].
1131
+
1132
+ [bold]Data Formats:[/bold]
1133
+ Supports CSV, TXT, JSON, and OHLCV data files. Non-OHLCV files are automatically converted.
1134
+ """ # noqa
1135
+
1136
+ # Expand script path
1137
+ if len(script.parts) == 1:
1138
+ script = app_state.scripts_dir / script
1139
+
1140
+ # If no script suffix, try .pine 1st
1141
+ if script.suffix == "":
1142
+ script = script.with_suffix(".pine")
1143
+ # If doesn't exist, try .py
1144
+ if not script.exists():
1145
+ script = script.with_suffix(".py")
1146
+
1147
+ # Check if script exists
1148
+ if not script.exists():
1149
+ secho(f"Script file '{script}' not found!", fg="red", err=True)
1150
+ raise Exit(1)
1151
+
1152
+ # Handle .pine files - compile them first
1153
+ if script.suffix == ".pine":
1154
+ # Read api.toml configuration
1155
+ api_config = {}
1156
+ try:
1157
+ with open(app_state.config_dir / 'api.toml', 'rb') as f:
1158
+ api_config = tomllib.load(f)['api']
1159
+ except KeyError:
1160
+ console.print("[red]Invalid API config file (api.toml)![/red]")
1161
+ raise Exit(1)
1162
+ except FileNotFoundError:
1163
+ pass
1164
+
1165
+ # Override API key if provided
1166
+ if api_key:
1167
+ api_config['api_key'] = api_key
1168
+
1169
+ # Override API URL if provided via environment variable
1170
+ api_url = os.getenv("PYNESYS_API_URL")
1171
+ if api_url:
1172
+ api_config['base_url'] = api_url
1173
+
1174
+ if api_config.get('api_key'):
1175
+ # Create the compiler instance
1176
+ compiler = PyneComp(**api_config)
1177
+
1178
+ # Determine output path for compiled file
1179
+ out_path = script.with_suffix(".py")
1180
+
1181
+ # Check if compilation is needed
1182
+ if compiler.needs_compilation(script, out_path):
1183
+ with APIErrorHandler(console):
1184
+ with Progress(
1185
+ SpinnerColumn(finished_text="[green]✓"),
1186
+ TextColumn("[progress.description]{task.description}"),
1187
+ console=console
1188
+ ) as progress:
1189
+ task = progress.add_task("Compiling Pine Script...", total=1)
1190
+
1191
+ # Compile the .pine file
1192
+ compiler.compile(script, out_path)
1193
+
1194
+ progress.update(task, completed=1)
1195
+
1196
+ # Update script to point to the compiled file
1197
+ script = out_path
1198
+
1199
+ # Go back to normal .py file
1200
+ else:
1201
+ script = script.with_suffix(".py")
1202
+ # Check if script exists
1203
+ if not script.exists():
1204
+ secho(f"Script file '{script}' not found!", fg="red", err=True)
1205
+ raise Exit(1)
1206
+
1207
+ # --- Data resolution: provider string or file path ---
1208
+ provider_mode = is_provider_string(data)
1209
+ provider_data = None
1210
+
1211
+ if live and not provider_mode:
1212
+ secho("Error: --live is only available in provider mode.", err=True, fg=colors.RED)
1213
+ raise Exit(1)
1214
+
1215
+ if provider_mode:
1216
+ # Provider mode: download historical warmup data + syminfo. A
1217
+ # long-running --broker/--live run rides out transient broker outages
1218
+ # (maintenance, lost route) instead of dying mid-startup; a one-shot
1219
+ # backtest and any permanent failure (unknown symbol, bad credentials)
1220
+ # still fail fast with a clean one-line error, not a traceback.
1221
+ provider_data = _download_provider_data_resilient(
1222
+ data, time_from, retry_transient=(live or broker),
1223
+ )
1224
+ data_path, syminfo = provider_data.ohlcv_path, provider_data.syminfo
1225
+ else:
1226
+ # File mode: resolve path, convert if needed
1227
+ data_path = Path(data)
1228
+
1229
+ if len(data_path.parts) == 1:
1230
+ data_path = app_state.data_dir / data_path
1231
+
1232
+ if data_path.suffix == "":
1233
+ ohlcv_path = data_path.with_suffix(".ohlcv")
1234
+ csv_path = data_path.with_suffix(".csv")
1235
+ if ohlcv_path.exists():
1236
+ data_path = ohlcv_path
1237
+ elif csv_path.exists():
1238
+ data_path = csv_path
1239
+ else:
1240
+ data_path = ohlcv_path
1241
+
1242
+ if data_path.suffix != ".ohlcv":
1243
+ try:
1244
+ converter = DataConverter()
1245
+ if converter.is_conversion_required(data_path):
1246
+ detected_symbol, detected_provider = DataConverter.guess_symbol_from_filename(data_path)
1247
+ if not detected_symbol:
1248
+ detected_symbol = data_path.stem.upper()
1249
+ with Progress(
1250
+ SpinnerColumn(finished_text="[green]✓"),
1251
+ TextColumn("[progress.description]{task.description}"),
1252
+ console=console
1253
+ ) as progress:
1254
+ task = progress.add_task(f"Converting {data_path.suffix} to OHLCV format...", total=1)
1255
+ converter.convert_to_ohlcv(
1256
+ data_path, provider=detected_provider,
1257
+ symbol=detected_symbol, force=True
1258
+ )
1259
+ data_path = data_path.with_suffix(".ohlcv")
1260
+ progress.update(task, completed=1)
1261
+ else:
1262
+ data_path = data_path.with_suffix(".ohlcv")
1263
+ except (DataFormatError, ConversionError) as e:
1264
+ secho(f"Conversion failed: {e}", fg="red", err=True)
1265
+ secho("Please convert the file manually:", fg="red")
1266
+ secho(f"pyne data convert-from {data_path}", fg="yellow")
1267
+ raise Exit(1)
1268
+
1269
+ if not data_path.exists():
1270
+ secho(f"Data file not found: {data_path.name}", fg="red", err=True)
1271
+ raise Exit(1)
1272
+
1273
+ try:
1274
+ syminfo = SymInfo.load_toml(data_path.with_suffix(".toml"))
1275
+ except FileNotFoundError:
1276
+ secho(f"Symbol info file '{data_path.with_suffix('.toml')}' not found!", fg="red", err=True)
1277
+ raise Exit(1)
1278
+
1279
+ # --- Output paths ---
1280
+ if plot_path and plot_path.suffix != ".csv":
1281
+ plot_path = plot_path.with_suffix(".csv")
1282
+ if not plot_path:
1283
+ plot_path = app_state.output_dir / f"{script.stem}.csv"
1284
+
1285
+ if strat_path and strat_path.suffix != ".csv":
1286
+ strat_path = strat_path.with_suffix(".csv")
1287
+ if not strat_path:
1288
+ strat_path = app_state.output_dir / f"{script.stem}_strat.csv"
1289
+
1290
+ if trade_path and trade_path.suffix != ".csv":
1291
+ trade_path = trade_path.with_suffix(".csv")
1292
+ if not trade_path:
1293
+ trade_path = app_state.output_dir / f"{script.stem}_trade.csv"
1294
+
1295
+ # --viz-path / --viz-journal both imply --viz
1296
+ if viz_path or viz_journal:
1297
+ viz = True
1298
+ if viz and not viz_path:
1299
+ viz_path = app_state.output_dir / f"{script.stem}_viz.ndjson"
1300
+
1301
+ # Validate and process --timeframe option
1302
+ magnifier_mode = False
1303
+ magnifier_source_tf: str | None = None
1304
+ if timeframe:
1305
+ chart_tf: str = timeframe.upper()
1306
+ try:
1307
+ in_seconds(chart_tf)
1308
+ except (ValueError, AssertionError):
1309
+ secho(f"Invalid timeframe: {chart_tf}. Must be a valid TradingView format "
1310
+ f"(e.g. '1', '5', '60', '1D', '1W', '1M').", fg="red", err=True)
1311
+ raise Exit(1)
1312
+
1313
+ data_tf = syminfo.period
1314
+ if chart_tf != data_tf:
1315
+ try:
1316
+ validate_aggregation(data_tf, chart_tf)
1317
+ except ValueError as e:
1318
+ secho(str(e), fg="red", err=True)
1319
+ raise Exit(1)
1320
+ # Override syminfo period to the chart timeframe
1321
+ syminfo.period = chart_tf
1322
+ magnifier_mode = True
1323
+ magnifier_source_tf = data_tf
1324
+
1325
+ # --- Open data and run ---
1326
+ with OHLCVReader(data_path) as reader:
1327
+ # Parse time range
1328
+ time_from_dt = _parse_time_value(time_from) if time_from and not provider_mode else None
1329
+ time_to_dt = _parse_time_value(time_to) if time_to else None
1330
+
1331
+ if not time_from_dt:
1332
+ # Provider bar-count mode pins the start to the N-th last
1333
+ # real bar; otherwise use the file's natural start.
1334
+ if provider_data is not None and provider_data.time_from_ts is not None:
1335
+ time_from_dt = datetime.fromtimestamp(
1336
+ provider_data.time_from_ts, UTC,
1337
+ )
1338
+ else:
1339
+ time_from_dt = reader.start_datetime
1340
+ if not time_to_dt:
1341
+ time_to_dt = reader.end_datetime
1342
+
1343
+ assert isinstance(time_from_dt, datetime) and isinstance(time_to_dt, datetime)
1344
+ time_from_ts = int(time_from_dt.timestamp())
1345
+ time_to_ts = int(time_to_dt.timestamp())
1346
+
1347
+ # Remove timezone for display purposes
1348
+ time_from_display = time_from_dt.replace(tzinfo=None)
1349
+ time_to_display = time_to_dt.replace(tzinfo=None)
1350
+
1351
+ total_seconds = int((time_to_display - time_from_display).total_seconds())
1352
+
1353
+ # Get the iterator using the correct UTC timestamps
1354
+ size = reader.get_size(time_from_ts, time_to_ts)
1355
+ # Pine anchors ``last_bar_time`` on historical bars to the chart's final
1356
+ # bar, known up front from the data window. Scan back over the writer's
1357
+ # gap-fill tail (``volume == -1`` records; ``not (volume < 0)`` keeps
1358
+ # NaN-volume real bars) for the last real bar of the window.
1359
+ last_bar_time = None
1360
+ start_pos, end_pos = reader.get_positions(time_from_ts, time_to_ts)
1361
+ for pos in range(end_pos - 1, start_pos - 1, -1):
1362
+ window_tail_bar = reader.read(pos)
1363
+ if not (window_tail_bar.volume < 0):
1364
+ last_bar_time = int(window_tail_bar.timestamp * 1000)
1365
+ break
1366
+ magnifier_iter = None
1367
+ if magnifier_mode:
1368
+ # Sub-TF data goes to magnifier; ohlcv_iter is unused (replaced in ScriptRunner)
1369
+ magnifier_iter = reader.read_from(time_from_ts, time_to_ts)
1370
+ ohlcv_iter = iter([])
1371
+ else:
1372
+ ohlcv_iter = reader.read_from(time_from_ts, time_to_ts)
1373
+
1374
+ # --broker implies --live.
1375
+ if broker:
1376
+ live = True
1377
+
1378
+ # Broker mode: verify plugin capability up front.
1379
+ broker_plugin = None
1380
+ broker_event_loop = None
1381
+ broker_event_loop_thread = None
1382
+ broker_store = None
1383
+ broker_store_ctx = None
1384
+ # Whether the live loop reached its explicit completion summary. When
1385
+ # it did not (an early raise, a crash mid-run) the outer ``finally``
1386
+ # narrates the cleanup outcome + next step so a failed run never ends
1387
+ # without a visible cleanup status.
1388
+ broker_run_reached_summary = False
1389
+ # Live OHLCV consumer generator — captured here so the outer
1390
+ # ``finally`` can close it explicitly *before* the broker event
1391
+ # loop is torn down. Without that, the consumer's own ``finally``
1392
+ # only runs when the runner is GC'd (after the function returns),
1393
+ # by which point ``broker_event_loop`` is already closed and the
1394
+ # producer thread's ``run_coroutine_threadsafe`` future is stuck
1395
+ # on a dead loop — its ``thread.join(shutdown_timeout + 5)`` then
1396
+ # blocks the whole ~125 s before the process actually exits.
1397
+ live_iter = None
1398
+ if broker:
1399
+ if not provider_data:
1400
+ secho("--broker requires a provider string (ccxt:EXCHANGE:SYMBOL@TIMEFRAME).",
1401
+ err=True, fg=colors.RED)
1402
+ raise Exit(1)
1403
+ from pynecore.core.plugin.broker import BrokerPlugin
1404
+ if not isinstance(provider_data.provider_instance, BrokerPlugin):
1405
+ secho(
1406
+ f"Plugin '{provider_data.parsed_string.provider}' is not a BrokerPlugin "
1407
+ f"— broker mode requires an exchange-backed plugin.",
1408
+ err=True, fg=colors.RED,
1409
+ )
1410
+ raise Exit(1)
1411
+ broker_plugin = provider_data.provider_instance
1412
+
1413
+ # Apply cross-broker runtime defaults (workdir/config/brokers.toml).
1414
+ # The policies are broker-agnostic; living here keeps the user-facing
1415
+ # knobs in one place and out of every plugin's own config.
1416
+ from pynecore.core.broker.defaults import load_broker_defaults
1417
+ broker_defaults = load_broker_defaults(app_state.config_dir)
1418
+ broker_plugin.on_unexpected_cancel = broker_defaults.on_unexpected_cancel
1419
+ broker_plugin.on_inventory_conflict = broker_defaults.on_inventory_conflict
1420
+
1421
+ # Probe the plugin against the BrokerPlugin authoring contract
1422
+ # before any storage or engine state exists. Authentication has
1423
+ # already run (``_download_provider_data`` drove it), so the
1424
+ # account-id lifecycle is checkable here — and the broker
1425
+ # storage below derives the run identity from it.
1426
+ from pynecore.core.broker.validation import validate_plugin_contract
1427
+ contract_errors, contract_warnings = validate_plugin_contract(
1428
+ broker_plugin, require_account_id=True,
1429
+ )
1430
+ for warning in contract_warnings:
1431
+ broker_warning("%s", warning)
1432
+ if contract_errors:
1433
+ secho(
1434
+ "Broker plugin contract violation(s):\n"
1435
+ + "\n".join(f" - {e}" for e in contract_errors),
1436
+ err=True, fg=colors.RED,
1437
+ )
1438
+ raise Exit(1)
1439
+
1440
+ broker_event_loop = asyncio.new_event_loop()
1441
+ # Drive the loop on a dedicated daemon thread. Broker plugin
1442
+ # coroutines are submitted from the (synchronous) Pine script
1443
+ # thread via ``run_coroutine_threadsafe``, which requires the
1444
+ # target loop to actually be running — without this pump every
1445
+ # broker call would park forever.
1446
+ broker_event_loop_thread = threading.Thread(
1447
+ target=broker_event_loop.run_forever,
1448
+ daemon=True,
1449
+ name="broker-event-loop",
1450
+ )
1451
+ broker_event_loop_thread.start()
1452
+
1453
+ # Open the unified broker storage and register a new run instance.
1454
+ # The plugin's account_id is already populated by this point —
1455
+ # _download_provider_data has driven authentication through the
1456
+ # provider side, and the plugin stashes the identifier during
1457
+ # its session setup.
1458
+ from pynecore.core.broker.run_identity import RunIdentity
1459
+ from pynecore.core.broker.storage import BrokerStore
1460
+ store_path = app_state.workdir / "output" / "logs" / "broker.sqlite"
1461
+ broker_store = BrokerStore(
1462
+ store_path, plugin_name=broker_plugin.plugin_name,
1463
+ )
1464
+ identity = RunIdentity(
1465
+ strategy_id=script.stem,
1466
+ symbol=str(syminfo.ticker),
1467
+ timeframe=str(syminfo.period or ""),
1468
+ account_id=broker_plugin.account_id,
1469
+ label=run_label,
1470
+ )
1471
+ try:
1472
+ broker_store_ctx = broker_store.open_run(
1473
+ identity,
1474
+ script_source=script.read_text(encoding='utf-8'),
1475
+ script_path=script,
1476
+ )
1477
+ except RuntimeError as e:
1478
+ secho(str(e), err=True, fg=colors.RED)
1479
+ broker_store.close()
1480
+ raise Exit(1)
1481
+ except BaseException:
1482
+ # open_run wraps its INSERT in a transaction: any failure before
1483
+ # it returns rolls the row back (no orphaned LIVE row) but leaves
1484
+ # the SQLite connection open. Close it so a startup crash here
1485
+ # cannot leak the store, then re-raise the real error.
1486
+ broker_store.close()
1487
+ raise
1488
+
1489
+ # The broker run is now open; every subsequent startup step
1490
+ # (security parsing, live iterator chaining, ScriptRunner import)
1491
+ # must run under the same try/finally that also wraps runner.run(),
1492
+ # otherwise an early raise would leave the active runs row with a
1493
+ # NULL ``ended_ts_ms`` and block the next startup of the same bot
1494
+ # until the stale-cleanup window (5 min) expires.
1495
+ try:
1496
+ # Validate live mode capability up front (the live iterator is
1497
+ # created only AFTER ``Loading PyneCore`` finishes, so its eager
1498
+ # ``WS connect`` log doesn't race the spinner).
1499
+ if live and provider_data:
1500
+ from pynecore.core.plugin.live_provider import LiveProviderPlugin
1501
+
1502
+ if not isinstance(provider_data.provider_instance, LiveProviderPlugin):
1503
+ secho(f"Plugin '{provider_data.parsed_string.provider}' does not support live data.",
1504
+ err=True, fg=colors.RED)
1505
+ raise Exit(1)
1506
+ assert provider_data.parsed_string.timeframe is not None
1507
+ size = 0
1508
+
1509
+ # Parse security data mappings. Two strict modes:
1510
+ #
1511
+ # --live: value is a plugin-native symbol that the chart
1512
+ # provider can serve (e.g. ``EURUSD`` on Capital.com).
1513
+ # The security subprocess opens its own provider,
1514
+ # downloads warmup in-memory, and streams live —
1515
+ # no ``.ohlcv`` file is involved.
1516
+ #
1517
+ # backtest: value is a static ``.ohlcv`` file (as before).
1518
+ #
1519
+ # Cross-mode values are rejected so a silent mismatch can't
1520
+ # produce wrong results.
1521
+ from pynecore.core.plugin.live_provider import PluginSymbol
1522
+ security_data: dict[str, str | Path | PluginSymbol] | None = None
1523
+ if security:
1524
+ sec_map: dict[str, str | Path | PluginSymbol] = {}
1525
+ for entry in security:
1526
+ if '=' not in entry:
1527
+ secho(
1528
+ f"Invalid --security format: '{entry}'. "
1529
+ f"Expected 'TIMEFRAME=value' or 'SYMBOL:TIMEFRAME=value'",
1530
+ fg="red", err=True,
1531
+ )
1532
+ raise Exit(1)
1533
+ key, value = entry.split('=', 1)
1534
+
1535
+ if live:
1536
+ # Live mode: value must be a plugin-native symbol,
1537
+ # not a file path. Native symbols can contain ``/``
1538
+ # (e.g. CCXT ``binance:ETH/USDT`` or ``BTC/USDT:USDT``),
1539
+ # so only reject values that *look* like a path: start
1540
+ # with a path separator/relative prefix or carry the
1541
+ # ``.ohlcv`` extension.
1542
+ looks_like_path = (
1543
+ value.startswith(('/', './', '../', '~/'))
1544
+ or value.endswith('.ohlcv')
1545
+ )
1546
+ if looks_like_path:
1547
+ secho(
1548
+ f"--security value '{value}' looks like a file path. "
1549
+ f"In --live mode the value must be a plugin-native "
1550
+ f"symbol (e.g. EURUSD or binance:ETH/USDT).",
1551
+ fg="red", err=True,
1552
+ )
1553
+ raise Exit(1)
1554
+ # The chart's provider is the one that will serve
1555
+ # the security warmup + live stream. Derive the
1556
+ # timeframe from the key: ``SYMBOL:TIMEFRAME`` or
1557
+ # bare ``TIMEFRAME``.
1558
+ assert provider_data is not None # --live already required provider mode
1559
+ sec_tf = key.rsplit(':', 1)[-1] if ':' in key else key
1560
+ sec_map[key] = PluginSymbol(
1561
+ provider_name=provider_data.parsed_string.provider,
1562
+ symbol=value,
1563
+ timeframe=sec_tf,
1564
+ config=getattr(provider_data.provider_instance, 'config', None),
1565
+ ohlcv_dir=app_state.data_dir,
1566
+ )
1567
+ else:
1568
+ # Backtest mode: value is a file stem or path.
1569
+ sec_path = Path(value)
1570
+ if len(sec_path.parts) == 1:
1571
+ sec_path = app_state.data_dir / sec_path
1572
+ # ``value`` may carry the ``.ohlcv`` data extension or be a
1573
+ # bare stem. Only ``.ohlcv`` is meaningful — a dot inside the
1574
+ # name belongs to the symbol (e.g. a perpetual ``BTCUSDT.P``),
1575
+ # so append/strip by name instead of ``with_suffix`` which
1576
+ # would clobber the symbol's own dotted tail.
1577
+ if sec_path.name.endswith('.ohlcv'):
1578
+ sec_path = sec_path.with_name(sec_path.name[:-len('.ohlcv')])
1579
+ ohlcv_check = sec_path.with_name(sec_path.name + '.ohlcv')
1580
+ if not ohlcv_check.exists():
1581
+ secho(
1582
+ f"Security data not found: {ohlcv_check}",
1583
+ fg="red", err=True,
1584
+ )
1585
+ raise Exit(1)
1586
+ sec_map[key] = str(sec_path)
1587
+ security_data = sec_map
1588
+
1589
+ # Add lib directory to Python path for library imports
1590
+ lib_dir = app_state.scripts_dir / "lib"
1591
+ lib_path_added = False
1592
+ if lib_dir.exists() and lib_dir.is_dir():
1593
+ sys.path.insert(0, str(lib_dir))
1594
+ lib_path_added = True
1595
+
1596
+ # Set live mode flags before ScriptRunner creation
1597
+ if live:
1598
+ from pynecore import lib as _lib
1599
+ _lib._is_live = True
1600
+ _lib._strategy_suppressed = True
1601
+
1602
+ # Show loading spinner while importing
1603
+ with Progress(
1604
+ SpinnerColumn(finished_text="[green]✓"),
1605
+ TextColumn("{task.description}"),
1606
+ ) as loading_progress:
1607
+ loading_task = loading_progress.add_task("Loading PyneCore...", total=1)
1608
+
1609
+ try:
1610
+ # Create script runner (this is where the import happens).
1611
+ # In live mode we pass the chart provider so security
1612
+ # contexts without an explicit ``--security`` mapping can
1613
+ # be auto-translated through the plugin's ``symbol_map``
1614
+ # / ``normalize_symbol``.
1615
+ chart_provider_name = None
1616
+ chart_provider_instance = None
1617
+ if live and provider_data is not None:
1618
+ chart_provider_name = provider_data.parsed_string.provider
1619
+ chart_provider_instance = provider_data.provider_instance
1620
+ runner = ScriptRunner(script, ohlcv_iter, syminfo, last_bar_index=size - 1,
1621
+ last_bar_time=last_bar_time,
1622
+ plot_path=plot_path, strat_path=strat_path, trade_path=trade_path,
1623
+ viz_path=viz_path if viz else None, viz_journal=viz_journal,
1624
+ security_data=security_data,
1625
+ magnifier_iter=magnifier_iter,
1626
+ magnifier_source_tf=magnifier_source_tf,
1627
+ broker_plugin=broker_plugin,
1628
+ broker_event_loop=broker_event_loop,
1629
+ broker_store_ctx=broker_store_ctx,
1630
+ log_ohlcv=live and not no_log_ohlcv,
1631
+ chart_provider_name=chart_provider_name,
1632
+ chart_provider_instance=chart_provider_instance,
1633
+ time_from=time_from_dt,
1634
+ chart_data_path=data_path,
1635
+ config_dir=app_state.config_dir)
1636
+ finally:
1637
+ # Remove lib directory from Python path
1638
+ if lib_path_added:
1639
+ sys.path.remove(str(lib_dir))
1640
+
1641
+ # Mark as completed
1642
+ loading_progress.update(loading_task, completed=1)
1643
+
1644
+ # --list-data: report the data this script needs (from
1645
+ # request.security calls) and exit before consuming any bar.
1646
+ if list_data:
1647
+ requirements = runner.list_data_requirements(
1648
+ chart_symbol=f"{syminfo.prefix}:{syminfo.ticker}",
1649
+ chart_tf=str(syminfo.period),
1650
+ security_keys=set(security_data or {}),
1651
+ )
1652
+ _print_data_requirements(requirements, script.name)
1653
+ raise Exit(0)
1654
+
1655
+ # Now that the script is loaded, start the live OHLCV stream.
1656
+ # ``live_ohlcv_generator`` eager-starts the WS connect (and
1657
+ # blocks until subscribed), so doing it AFTER the spinner keeps
1658
+ # the ``[BROKER] WS connect …`` lines below ``✓ Loading PyneCore``
1659
+ # in the user-visible startup log.
1660
+ if live and provider_data:
1661
+ import itertools
1662
+ assert provider_data.parsed_string.timeframe is not None
1663
+ live_iter = live_ohlcv_generator(
1664
+ provider=provider_data.provider_instance,
1665
+ symbol=provider_data.parsed_string.symbol,
1666
+ timeframe=provider_data.parsed_string.timeframe,
1667
+ syminfo=syminfo,
1668
+ last_historical_timestamp=time_to_ts,
1669
+ shutdown_timeout=shutdown_timeout,
1670
+ event_loop=broker_event_loop,
1671
+ # Broker mode: a warmup-connect failure must surface its
1672
+ # real cause here, not be masked by start_broker()'s
1673
+ # reconcile ("live connection not established").
1674
+ raise_on_connect_failure=broker_plugin is not None,
1675
+ )
1676
+ runner.ohlcv_iter = itertools.chain(runner.ohlcv_iter, live_iter)
1677
+
1678
+ # Start broker-side I/O (watch_orders task + startup reconcile).
1679
+ # No-op when ``broker_plugin`` is None.
1680
+ runner.start_broker()
1681
+
1682
+ if live:
1683
+ # Share the Pine logger's Console with the live Progress so
1684
+ # `logger.info(...)` lines render above the spinner rather
1685
+ # than colliding with it. The PineRichHandler owns the only
1686
+ # Console; reusing it keeps Rich's Live-intercept coherent.
1687
+ live_console = None
1688
+ for _h in pyne_logger.handlers:
1689
+ _h_console = getattr(_h, 'console', None)
1690
+ if _h_console is not None:
1691
+ live_console = _h_console
1692
+ break
1693
+
1694
+ # Latest quote snapshot — the tick hook updates these so the
1695
+ # spinner text carries the current bid/ask even between bar
1696
+ # closes.
1697
+ spinner_state: dict[str, Any] = {
1698
+ 'bid': None,
1699
+ 'ask': None,
1700
+ 'price': None,
1701
+ 'last_mid': None,
1702
+ 'arrow': ' ',
1703
+ }
1704
+
1705
+ # Derive fixed price decimals from ``syminfo.mintick`` so the
1706
+ # spinner prices keep a constant width (``1.16830`` /
1707
+ # ``1.16837`` instead of ``1.1683`` / ``1.16837``). Matches
1708
+ # the same computation in ScriptRunner for the OHLCV log,
1709
+ # including the 2-decimal fallback for missing/zero mintick.
1710
+ _mintick = getattr(syminfo, 'mintick', 0.0) or 0.0
1711
+ price_decimals = mintick_decimals(_mintick) if _mintick > 0 else 2
1712
+
1713
+ def _spinner_text() -> str:
1714
+ bid = spinner_state['bid']
1715
+ ask = spinner_state['ask']
1716
+ d = price_decimals
1717
+ position_obj = getattr(runner.script, 'position', None)
1718
+ # Paper trading (``--live`` without ``--broker``) has no
1719
+ # broker balance, so synthesise one from the simulator's
1720
+ # equity — otherwise ``_broker_metrics_text`` bails early
1721
+ # and the spinner shows no position/PnL at all.
1722
+ balance = runner.broker_balance
1723
+ if balance is None and position_obj is not None:
1724
+ eq = _coerce_finite_float(getattr(position_obj, 'equity', None))
1725
+ if eq is not None:
1726
+ balance = {getattr(syminfo, 'currency', None) or '': eq}
1727
+ metrics = _broker_metrics_text(
1728
+ position_obj,
1729
+ runner.broker_position_snapshot,
1730
+ balance,
1731
+ getattr(syminfo, 'currency', None),
1732
+ d,
1733
+ bid,
1734
+ ask,
1735
+ spinner_state['price'],
1736
+ )
1737
+ suffix = f" {metrics}" if metrics else ""
1738
+ if bid is not None and ask is not None:
1739
+ arrow = spinner_state['arrow']
1740
+ return (f"[green]{bid:.{d}f}[/] {arrow} "
1741
+ f"[red]{ask:.{d}f}[/]{suffix}")
1742
+ if bid is not None:
1743
+ return f"[green]{bid:.{d}f}[/]{suffix}"
1744
+ return f"Live streaming...{suffix}"
1745
+
1746
+ # ``PYNE_NO_LIVE_SPINNER`` suppresses the per-tick spinner so
1747
+ # systemd journals / Docker logs only carry whole log lines
1748
+ # (``[BROKER]``, ``[OHLCV]``, Pine ``log.*``) instead of the
1749
+ # spinner refresh stream. Rich already disables live rendering
1750
+ # on a non-TTY, but explicit opt-out works regardless of how
1751
+ # the harness wires stdout.
1752
+ _spinner_disabled = (
1753
+ os.environ.get("PYNE_NO_LIVE_SPINNER", "").lower()
1754
+ not in ("", "0", "false", "no", "off")
1755
+ )
1756
+ if _spinner_disabled:
1757
+ broker_info("live spinner disabled (PYNE_NO_LIVE_SPINNER)")
1758
+
1759
+ # Live mode: spinner instead of progress bar (no known end time)
1760
+ # ``transient=True`` clears the live spinner row when the
1761
+ # ``with`` block exits, so the user-visible tail is just the
1762
+ # ``[BROKER]`` log lines — no orphaned ``⠧ Live —``
1763
+ # snapshot frozen below the final halt entry.
1764
+ with Progress(
1765
+ SpinnerColumn(),
1766
+ ExchangeClockColumn(runner.tz),
1767
+ CustomTimeElapsedColumn(),
1768
+ TextColumn("{task.description}"),
1769
+ console=live_console,
1770
+ transient=True,
1771
+ disable=_spinner_disabled,
1772
+ refresh_per_second=10,
1773
+ ) as progress:
1774
+ task = progress.add_task(description="Live streaming...", total=None)
1775
+
1776
+ def _apply_quote(new_bid: float | None,
1777
+ new_ask: float | None) -> None:
1778
+ """Update bid/ask + midline arrow in spinner_state."""
1779
+ if new_bid is not None and new_ask is not None:
1780
+ new_mid = (new_bid + new_ask) / 2
1781
+ last_mid = spinner_state['last_mid']
1782
+ if last_mid is not None:
1783
+ if new_mid > last_mid:
1784
+ spinner_state['arrow'] = '[green]▲[/]'
1785
+ elif new_mid < last_mid:
1786
+ spinner_state['arrow'] = '[red]▼[/]'
1787
+ spinner_state['last_mid'] = new_mid
1788
+ if new_bid is not None:
1789
+ spinner_state['bid'] = new_bid
1790
+ if new_ask is not None:
1791
+ spinner_state['ask'] = new_ask
1792
+
1793
+ def cb_progress_live(current_time: datetime | None):
1794
+ if current_time is not None:
1795
+ progress.update(task, description=_spinner_text())
1796
+
1797
+ def cb_tick_live(candle):
1798
+ extra = candle.extra_fields or {}
1799
+ spinner_state['price'] = candle.close
1800
+ # Prefer the live quote snapshot over ``candle.close``:
1801
+ # on a closed OHLC bar ``candle.close`` is the previous
1802
+ # period's bid-side close, not the current quote.
1803
+ _apply_quote(
1804
+ extra.get('bid_close', candle.close),
1805
+ extra.get('ask_close'),
1806
+ )
1807
+ progress.update(task, description=_spinner_text())
1808
+
1809
+ stop_reason = "completed"
1810
+ # Translate SIGTERM (the signal a supervisor / lab harness
1811
+ # sends to stop the process) into the same graceful
1812
+ # ``KeyboardInterrupt`` path as Ctrl-C, so a stopped run
1813
+ # still tears down cleanly and prints its completion +
1814
+ # cleanup summary instead of dying at return_code=-15 with
1815
+ # no visible cleanup outcome.
1816
+ _prev_sigterm = None
1817
+ _sigterm_installed = False
1818
+ if broker_plugin is not None:
1819
+ def _on_sigterm(_signum, _frame):
1820
+ raise KeyboardInterrupt
1821
+ try:
1822
+ _prev_sigterm = signal.getsignal(signal.SIGTERM)
1823
+ signal.signal(signal.SIGTERM, _on_sigterm)
1824
+ _sigterm_installed = True
1825
+ except (ValueError, OSError):
1826
+ # signal.signal only works on the main thread; a
1827
+ # non-main-thread run keeps the default disposition.
1828
+ _sigterm_installed = False
1829
+ # When the spinner is suppressed (durable log / non-TTY)
1830
+ # a quiet phase would otherwise print nothing for tens of
1831
+ # seconds; a periodic heartbeat keeps the transcript alive
1832
+ # so a healthy wait is distinguishable from a hang.
1833
+ heartbeat = None
1834
+ if _spinner_disabled:
1835
+ _hb_interval = _resolve_heartbeat_interval(
1836
+ os.environ.get("PYNE_HEARTBEAT_INTERVAL"),
1837
+ )
1838
+ if _hb_interval > 0.0:
1839
+ heartbeat = _LiveHeartbeat(
1840
+ _hb_interval,
1841
+ lambda elapsed: broker_info(
1842
+ "still running — %.0fs elapsed, waiting "
1843
+ "for market data / next event", elapsed,
1844
+ ),
1845
+ )
1846
+ heartbeat.start()
1847
+ try:
1848
+ runner.run(on_progress=cb_progress_live,
1849
+ on_tick=cb_tick_live)
1850
+ except KeyboardInterrupt:
1851
+ # Tear down the spinner BEFORE the follow-up log line
1852
+ # so it doesn't print over the bottom of the screen
1853
+ # — Rich captures the live region as a snapshot below
1854
+ # every log line, otherwise leaving an orphan
1855
+ # ``⠧ Live — …`` row in the transcript.
1856
+ progress.stop()
1857
+ stop_reason = "interrupted"
1858
+ broker_warning("live streaming stopped (interrupted)")
1859
+ except BrokerManualInterventionError:
1860
+ # The ``[BROKER] ERROR sync engine halted by …`` line
1861
+ # logged from ``OrderSyncEngine._record_halt`` already
1862
+ # carries the reason + context. Just append a single
1863
+ # follow-up hint in the same Pine log format so the
1864
+ # operator knows the strategy is no longer running.
1865
+ progress.stop()
1866
+ stop_reason = "manual intervention required"
1867
+ broker_warning(
1868
+ "live streaming stopped — manual intervention "
1869
+ "required, resolve the broker-side state and "
1870
+ "restart"
1871
+ )
1872
+ finally:
1873
+ if heartbeat is not None:
1874
+ heartbeat.stop()
1875
+ if _sigterm_installed:
1876
+ try:
1877
+ signal.signal(signal.SIGTERM, _prev_sigterm)
1878
+ except (ValueError, OSError):
1879
+ pass
1880
+
1881
+ # Explicit end-of-run summary so a successful broker command
1882
+ # confirms it stopped and reports its final position/equity
1883
+ # instead of an unadorned end of transcript.
1884
+ if broker_plugin is not None:
1885
+ broker_info("%s", _format_run_completion_summary(
1886
+ stop_reason,
1887
+ getattr(runner.script, 'position', None),
1888
+ runner.broker_position_snapshot,
1889
+ runner.broker_balance,
1890
+ getattr(syminfo, 'currency', None),
1891
+ ))
1892
+ broker_run_reached_summary = True
1893
+
1894
+ else:
1895
+ # Batch mode: progress bar with time range
1896
+ with Progress(
1897
+ SpinnerColumn(finished_text="[green]✓"),
1898
+ TextColumn("{task.description}"),
1899
+ DateColumn(time_from_display),
1900
+ BarColumn(),
1901
+ CustomTimeElapsedColumn(),
1902
+ "/",
1903
+ CustomTimeRemainingColumn(),
1904
+ ) as progress:
1905
+ task = progress.add_task(
1906
+ description="Running script...",
1907
+ total=total_seconds,
1908
+ )
1909
+
1910
+ # Create queue for progress updates
1911
+ progress_queue = queue.Queue()
1912
+ stop_event = threading.Event()
1913
+
1914
+ def progress_worker():
1915
+ """Worker thread that updates progress bar at 30Hz"""
1916
+ last_update = 0
1917
+ while not stop_event.is_set():
1918
+ try:
1919
+ # Drain all pending updates
1920
+ current_time = None
1921
+ while True:
1922
+ try:
1923
+ current_time = progress_queue.get_nowait()
1924
+ except queue.Empty:
1925
+ break
1926
+
1927
+ # Update progress if we have new data
1928
+ if current_time is not None:
1929
+ if current_time == datetime.max:
1930
+ current_time = time_to_display
1931
+ elapsed_seconds = int(
1932
+ (current_time - time_from_display).total_seconds())
1933
+ if elapsed_seconds != last_update:
1934
+ progress.update(task, completed=elapsed_seconds)
1935
+ last_update = elapsed_seconds
1936
+ except Exception: # noqa
1937
+ pass
1938
+
1939
+ time.sleep(1 / 30)
1940
+
1941
+ # Start worker thread
1942
+ worker = threading.Thread(target=progress_worker, daemon=True)
1943
+ worker.start()
1944
+
1945
+ def cb_progress(current_time: datetime | None):
1946
+ """Callback that just puts timestamp in queue"""
1947
+ try:
1948
+ progress_queue.put_nowait(current_time)
1949
+ except queue.Full:
1950
+ pass
1951
+
1952
+ try:
1953
+ runner.run(on_progress=cb_progress)
1954
+
1955
+ progress_queue.put(time_to_display)
1956
+ time.sleep(0.05)
1957
+
1958
+ progress.update(task, completed=total_seconds)
1959
+ finally:
1960
+ stop_event.set()
1961
+ worker.join(timeout=0.1)
1962
+ progress.refresh()
1963
+ finally:
1964
+ # A broker run that never reached its completion summary ended
1965
+ # abnormally (early raise / crash mid-run). Narrate that the
1966
+ # teardown below still runs and what to do next, so a failed run
1967
+ # does not end without a visible cleanup outcome.
1968
+ if broker_plugin is not None and not broker_run_reached_summary:
1969
+ broker_warning(
1970
+ "run ended before a clean stop — closing broker storage "
1971
+ "and stopping the event loop; review the last [BROKER] "
1972
+ "entries and re-run to reconcile before trading again"
1973
+ )
1974
+ # Close the live OHLCV consumer first so its ``finally`` can
1975
+ # signal ``stop_event`` and join the producer thread WHILE the
1976
+ # broker event loop is still alive — otherwise the producer's
1977
+ # ``run_coroutine_threadsafe`` future never completes and the
1978
+ # join times out at ``shutdown_timeout + 5`` seconds.
1979
+ if live_iter is not None:
1980
+ try:
1981
+ live_iter.close()
1982
+ except RuntimeError:
1983
+ # Generator.close() only raises RuntimeError when the
1984
+ # generator swallows GeneratorExit and yields again —
1985
+ # harmless during teardown.
1986
+ pass
1987
+ # Close the broker storage run cleanly — happy-path teardown.
1988
+ # Crash paths (SIGKILL, OOM) are handled by the storage's
1989
+ # stale-run cleanup.
1990
+ if broker_store_ctx is not None:
1991
+ broker_store_ctx.close()
1992
+ if broker_store is not None:
1993
+ broker_store.close()
1994
+ # Stop the broker event-loop pump thread before process exit, then
1995
+ # close the loop only once its thread has actually exited
1996
+ # ``run_forever``. Closing a still-running loop raises
1997
+ # ``RuntimeError: Cannot close a running event loop``.
1998
+ if broker_event_loop is not None:
1999
+ if not _shutdown_broker_event_loop(
2000
+ broker_event_loop, broker_event_loop_thread, shutdown_timeout,
2001
+ ):
2002
+ broker_warning(
2003
+ "Broker event loop did not stop within %.0fs; "
2004
+ "leaving it open to avoid a close-while-running crash.",
2005
+ shutdown_timeout,
2006
+ )