opencode-pyneruntime 6.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- pynecore/utils/stdlib_checker.py +17 -0
|
@@ -0,0 +1,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
|
+
)
|