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