tradetropy 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. tradetropy/__init__.py +188 -0
  2. tradetropy/backtest/__init__.py +3 -0
  3. tradetropy/backtest/_build.py +495 -0
  4. tradetropy/backtest/_runner.py +673 -0
  5. tradetropy/backtest/_shm_bundle.py +131 -0
  6. tradetropy/backtest/_validation.py +35 -0
  7. tradetropy/backtest/engine.py +818 -0
  8. tradetropy/backtest/pool.py +1184 -0
  9. tradetropy/backtest/pool_adapter.py +122 -0
  10. tradetropy/connectors/__init__.py +6 -0
  11. tradetropy/connectors/_disclaimer.py +97 -0
  12. tradetropy/connectors/binance.py +301 -0
  13. tradetropy/connectors/bybit.py +1045 -0
  14. tradetropy/connectors/ccxt.py +1076 -0
  15. tradetropy/connectors/mt5.py +1125 -0
  16. tradetropy/core/__init__.py +36 -0
  17. tradetropy/core/broker.py +2195 -0
  18. tradetropy/core/constants.py +297 -0
  19. tradetropy/core/data_types.py +1724 -0
  20. tradetropy/data/__init__.py +34 -0
  21. tradetropy/data/_book_replay.py +222 -0
  22. tradetropy/data/_klines.py +450 -0
  23. tradetropy/data/_proxy.py +809 -0
  24. tradetropy/data/_ring.py +649 -0
  25. tradetropy/data/_store.py +120 -0
  26. tradetropy/data/_views.py +604 -0
  27. tradetropy/data/data.py +37 -0
  28. tradetropy/datasets/__init__.py +354 -0
  29. tradetropy/datasets/_data/AAPL_1d.csv +301 -0
  30. tradetropy/datasets/_data/ADAUSDT_1m.npz +0 -0
  31. tradetropy/datasets/_data/ADAUSDT_book.npz +0 -0
  32. tradetropy/datasets/_data/ADAUSDT_ticks.npz +0 -0
  33. tradetropy/datasets/_data/BTCUSDT_1m.npz +0 -0
  34. tradetropy/datasets/_data/GOOG_1d.csv +301 -0
  35. tradetropy/datasets/_data/MESU26_ticks.npz +0 -0
  36. tradetropy/datasets/_data/MNQU26_ticks.npz +0 -0
  37. tradetropy/exceptions.py +113 -0
  38. tradetropy/io/__init__.py +17 -0
  39. tradetropy/io/io.py +1721 -0
  40. tradetropy/live/__init__.py +5 -0
  41. tradetropy/live/_builder.py +738 -0
  42. tradetropy/live/_feed.py +713 -0
  43. tradetropy/live/_loop.py +337 -0
  44. tradetropy/live/_warmup.py +231 -0
  45. tradetropy/live/engine.py +579 -0
  46. tradetropy/logger.py +573 -0
  47. tradetropy/models/__init__.py +4 -0
  48. tradetropy/models/_warmup_policy.py +228 -0
  49. tradetropy/models/decorators.py +124 -0
  50. tradetropy/models/footprint/__init__.py +13 -0
  51. tradetropy/models/footprint/_compute.py +105 -0
  52. tradetropy/models/footprint/_config.py +62 -0
  53. tradetropy/models/footprint/_construction.py +186 -0
  54. tradetropy/models/footprint/_proxy.py +138 -0
  55. tradetropy/models/footprint/_ring.py +128 -0
  56. tradetropy/models/footprint/_store.py +70 -0
  57. tradetropy/models/footprint/_types.py +58 -0
  58. tradetropy/models/strategy.py +1525 -0
  59. tradetropy/optimize/__init__.py +13 -0
  60. tradetropy/optimize/grid_search.py +48 -0
  61. tradetropy/optimize/optimizer.py +82 -0
  62. tradetropy/optimize/random_search.py +36 -0
  63. tradetropy/optimize/reporter.py +68 -0
  64. tradetropy/optimize/result.py +175 -0
  65. tradetropy/optimize/space.py +141 -0
  66. tradetropy/optimize/task.py +156 -0
  67. tradetropy/playback/__init__.py +15 -0
  68. tradetropy/playback/base.py +1174 -0
  69. tradetropy/plotting/__init__.py +3 -0
  70. tradetropy/plotting/_fig_factory.py +111 -0
  71. tradetropy/plotting/_layout.py +829 -0
  72. tradetropy/plotting/_util.py +851 -0
  73. tradetropy/plotting/chart.py +16 -0
  74. tradetropy/plotting/config.py +250 -0
  75. tradetropy/plotting/js/__init__.py +0 -0
  76. tradetropy/plotting/js/autoscale_equity.js +19 -0
  77. tradetropy/plotting/js/autoscale_indicator.js +24 -0
  78. tradetropy/plotting/js/autoscale_ohlc.js +59 -0
  79. tradetropy/plotting/js/autoscale_ohlc_live.js +64 -0
  80. tradetropy/plotting/js/autoscale_primitives.js +60 -0
  81. tradetropy/plotting/js/autoscale_volume.js +15 -0
  82. tradetropy/plotting/js/fp_lazy.js +24 -0
  83. tradetropy/plotting/js/fp_lazy_live.js +24 -0
  84. tradetropy/plotting/js/fp_yaxis_populate.js +11 -0
  85. tradetropy/plotting/js/lazy_labels.js +21 -0
  86. tradetropy/plotting/js/lazy_labels_group.js +15 -0
  87. tradetropy/plotting/js/legend_toggle.js +8 -0
  88. tradetropy/plotting/js/ylock_reset.js +7 -0
  89. tradetropy/plotting/js/ylock_watch.js +12 -0
  90. tradetropy/plotting/plotting.py +616 -0
  91. tradetropy/plotting/render/__init__.py +12 -0
  92. tradetropy/plotting/render/_annotations.py +11 -0
  93. tradetropy/plotting/render/_equity.py +153 -0
  94. tradetropy/plotting/render/_indicators.py +230 -0
  95. tradetropy/plotting/render/_ohlc_volume.py +167 -0
  96. tradetropy/plotting/render/_panels.py +225 -0
  97. tradetropy/plotting/render/_tools.py +407 -0
  98. tradetropy/plotting/render/_trades.py +237 -0
  99. tradetropy/plotting/sources.py +610 -0
  100. tradetropy/plotting/theme/__init__.py +31 -0
  101. tradetropy/plotting/theme/dark.py +50 -0
  102. tradetropy/plotting/theme/light.py +50 -0
  103. tradetropy/plotting/theme/registry.py +41 -0
  104. tradetropy/session/__init__.py +18 -0
  105. tradetropy/session/base.py +1256 -0
  106. tradetropy/session/fake_live_sesh.py +746 -0
  107. tradetropy/signal.py +566 -0
  108. tradetropy/stats/__init__.py +43 -0
  109. tradetropy/stats/_fast.py +517 -0
  110. tradetropy/stats/stats.py +1382 -0
  111. tradetropy/streaming/__init__.py +65 -0
  112. tradetropy/streaming/_protocol.py +292 -0
  113. tradetropy/streaming/base.py +420 -0
  114. tradetropy/streaming/ccxt_pro.py +397 -0
  115. tradetropy/streaming/fake.py +73 -0
  116. tradetropy/streaming/replay_feed.py +157 -0
  117. tradetropy/ta/__init__.py +63 -0
  118. tradetropy/ta/_volume_profile.py +1204 -0
  119. tradetropy/ta/annotations.py +1857 -0
  120. tradetropy/ta/base.py +479 -0
  121. tradetropy/ta/bill_williams.py +439 -0
  122. tradetropy/ta/draw.py +137 -0
  123. tradetropy/ta/momentum.py +2130 -0
  124. tradetropy/ta/order_flow/__init__.py +79 -0
  125. tradetropy/ta/order_flow/_core.py +2891 -0
  126. tradetropy/ta/order_flow/large_trades.py +398 -0
  127. tradetropy/ta/pattern/__init__.py +87 -0
  128. tradetropy/ta/pattern/pivot_mixin.py +87 -0
  129. tradetropy/ta/structure/__init__.py +10 -0
  130. tradetropy/ta/structure/_utils.py +33 -0
  131. tradetropy/ta/structure/hhll.py +173 -0
  132. tradetropy/ta/structure/nbs.py +264 -0
  133. tradetropy/ta/structure/pivots.py +334 -0
  134. tradetropy/ta/structure/swings.py +454 -0
  135. tradetropy/ta/structure/zigzag.py +145 -0
  136. tradetropy/ta/tool/__init__.py +56 -0
  137. tradetropy/ta/tool/base.py +136 -0
  138. tradetropy/ta/tool/draw.py +82 -0
  139. tradetropy/ta/tool/fibonacci.py +209 -0
  140. tradetropy/ta/tool/volume_profile.py +368 -0
  141. tradetropy/ta/trend.py +542 -0
  142. tradetropy/ta/volatility.py +237 -0
  143. tradetropy/ta/volume.py +704 -0
  144. tradetropy-0.2.0.dist-info/METADATA +175 -0
  145. tradetropy-0.2.0.dist-info/RECORD +146 -0
  146. tradetropy-0.2.0.dist-info/WHEEL +4 -0
tradetropy/__init__.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ tradetropy - professional backtesting and live trading framework.
3
+
4
+ The public API is exposed lazily (PEP 562). Importing ``tradetropy`` itself is
5
+ cheap: heavy subsystems (pandas via ``stats``/``io``, ``live``, ``plotting``,
6
+ ``robustness``) are only imported the first time one of their names is
7
+ accessed. This keeps the import cost of a pure backtest/optimize workflow -
8
+ and of every spawned optimize/pool worker on Windows/macOS - close to the
9
+ numpy floor, instead of paying for plotting, live and robustness that a
10
+ worker never touches.
11
+
12
+ The exported surface is unchanged; ``from tradetropy import BacktestEngine``,
13
+ ``tradetropy.SMA`` and ``from tradetropy import *`` all keep working exactly as
14
+ before.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib
20
+ from typing import TYPE_CHECKING
21
+
22
+ # ``exceptions`` is tiny (no third-party imports) and is referenced as the
23
+ # submodule ``tradetropy.exceptions`` throughout the codebase, so it stays eager.
24
+ from tradetropy import exceptions
25
+
26
+ # ══════════════════════════════════════════════════════════════════════════════
27
+ # Lazy public-symbol registry: name -> (submodule, attribute)
28
+ # ══════════════════════════════════════════════════════════════════════════════
29
+ #
30
+ # Each entry maps a public name to the module that defines it. Nothing here is
31
+ # imported until the name is first accessed (see ``__getattr__`` below), so a
32
+ # process that only runs backtests never imports ``live``/``plotting``/
33
+ # ``robustness`` and only pays for ``pandas`` when it actually computes stats.
34
+ #
35
+ # The ``# >>> tier:N`` / ``# <<< tier:N`` marker blocks are consumed by
36
+ # tools/generate_tier.py to gate optional names out of lower-tier builds; keep
37
+ # each gated name's registry entry AND its ``__all__`` entry inside the markers.
38
+
39
+ _LAZY: dict[str, tuple[str, str]] = {
40
+ # -- Engines ---------------------------------------------------------------
41
+ "BacktestEngine": ("tradetropy.backtest", "BacktestEngine"),
42
+ "PoolBacktestEngine": ("tradetropy.backtest", "PoolBacktestEngine"),
43
+ "LiveEngine": ("tradetropy.live", "LiveEngine"),
44
+
45
+ # -- Disclaimer ------------------------------------------------------------
46
+ "LIVE_DISCLAIMER": ("tradetropy.connectors._disclaimer", "LIVE_DISCLAIMER"),
47
+
48
+ # -- Strategy / sessions ---------------------------------------------------
49
+ "Strategy": ("tradetropy.models", "Strategy"),
50
+ "FootprintConfig": ("tradetropy.models", "FootprintConfig"),
51
+ "SeshSimulatorBase": ("tradetropy.session", "SeshSimulatorBase"),
52
+
53
+ # -- Core data types -------------------------------------------------------
54
+ "TickData": ("tradetropy.core", "TickData"),
55
+ "KlineData": ("tradetropy.core", "KlineData"),
56
+ "parse_timeframe": ("tradetropy.core", "parse_timeframe"),
57
+ "TIMEFRAME_PRESETS": ("tradetropy.core", "TIMEFRAME_PRESETS"),
58
+
59
+ # -- Stats -----------------------------------------------------------------
60
+ "Stats": ("tradetropy.stats", "Stats"),
61
+ "compute_stats": ("tradetropy.stats", "compute_stats"),
62
+
63
+ # -- Robustness ------------------------------------------------------------
64
+
65
+ # -- Plotting --------------------------------------------------------------
66
+ "plot": ("tradetropy.plotting", "plot"),
67
+ "PlotConfig": ("tradetropy.plotting", "PlotConfig"),
68
+
69
+ # -- Indicators (tradetropy.ta) ----------------------------------------------
70
+ "Indicator": ("tradetropy.ta", "Indicator"),
71
+ "IndicatorPlotConfig": ("tradetropy.ta", "IndicatorPlotConfig"),
72
+ "SMA": ("tradetropy.ta", "SMA"),
73
+ "EMA": ("tradetropy.ta", "EMA"),
74
+ "MACD": ("tradetropy.ta", "MACD"),
75
+ "RSI": ("tradetropy.ta", "RSI"),
76
+ "ATR": ("tradetropy.ta", "ATR"),
77
+ "BollingerBands": ("tradetropy.ta", "BollingerBands"),
78
+ "VolumeProfile": ("tradetropy.ta", "VolumeProfile"),
79
+ "RollingVolumeProfile": ("tradetropy.ta", "RollingVolumeProfile"),
80
+ "VolumeNode": ("tradetropy.ta", "VolumeNode"),
81
+ "detect_volume_nodes": ("tradetropy.ta", "detect_volume_nodes"),
82
+ "ZigZag": ("tradetropy.ta", "ZigZag"),
83
+ "ConfirmedPivot": ("tradetropy.ta", "ConfirmedPivot"),
84
+ "PivotHighLow": ("tradetropy.ta", "PivotHighLow"),
85
+ "SwingHL": ("tradetropy.ta", "SwingHL"),
86
+ "EqualHL": ("tradetropy.ta", "EqualHL"),
87
+ "FairValueGap": ("tradetropy.ta", "FairValueGap"),
88
+ "OrderBlock": ("tradetropy.ta", "OrderBlock"),
89
+ "MarketSessions": ("tradetropy.ta", "MarketSessions"),
90
+ "SessionLevels": ("tradetropy.ta", "SessionLevels"),
91
+ "KillZones": ("tradetropy.ta", "KillZones"),
92
+ "LargeTrades": ("tradetropy.ta", "LargeTrades"),
93
+ }
94
+
95
+ __all__ = [
96
+ "exceptions",
97
+ "BacktestEngine",
98
+ "PoolBacktestEngine",
99
+ "LiveEngine",
100
+ "LIVE_DISCLAIMER",
101
+ "Strategy",
102
+ "FootprintConfig",
103
+ "SeshSimulatorBase",
104
+ "TickData",
105
+ "KlineData",
106
+ "parse_timeframe",
107
+ "TIMEFRAME_PRESETS",
108
+ "Stats",
109
+ "compute_stats",
110
+ "plot",
111
+ "PlotConfig",
112
+ "Indicator",
113
+ "IndicatorPlotConfig",
114
+ "SMA",
115
+ "EMA",
116
+ "MACD",
117
+ "RSI",
118
+ "ATR",
119
+ "BollingerBands",
120
+ "VolumeProfile",
121
+ "RollingVolumeProfile",
122
+ "VolumeNode",
123
+ "detect_volume_nodes",
124
+ "ZigZag",
125
+ "ConfirmedPivot",
126
+ "PivotHighLow",
127
+ "SwingHL",
128
+ "EqualHL",
129
+ "FairValueGap",
130
+ "OrderBlock",
131
+ "MarketSessions",
132
+ "SessionLevels",
133
+ "KillZones",
134
+ "LargeTrades",
135
+ ]
136
+
137
+
138
+ def __getattr__(name: str):
139
+ """
140
+ Resolve a public name lazily (PEP 562).
141
+
142
+ Looks the name up in the lazy registry, imports the owning submodule on
143
+ first access, caches the resolved object in the module globals (so later
144
+ lookups skip this path), and returns it. Falls back to importing a
145
+ same-named submodule, then raises AttributeError.
146
+ """
147
+ entry = _LAZY.get(name)
148
+ if entry is not None:
149
+ module_path, attr = entry
150
+ module = importlib.import_module(module_path)
151
+ value = getattr(module, attr)
152
+ globals()[name] = value
153
+ return value
154
+
155
+ # Fall back to a real submodule access (e.g. ``tradetropy.core``).
156
+ try:
157
+ module = importlib.import_module(f"{__name__}.{name}")
158
+ globals()[name] = module
159
+ return module
160
+ except ImportError:
161
+ pass
162
+
163
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
164
+
165
+
166
+ def __dir__() -> list[str]:
167
+ """Expose the full public surface to ``dir()`` and tab completion."""
168
+ return sorted(set(globals()) | set(_LAZY))
169
+
170
+
171
+ # Give static analysers / IDEs the real symbols without any runtime import cost.
172
+ if TYPE_CHECKING: # pragma: no cover
173
+ from tradetropy.backtest import BacktestEngine, PoolBacktestEngine
174
+ from tradetropy.live import LiveEngine, LivePool, Recorder
175
+ from tradetropy.connectors._disclaimer import LIVE_DISCLAIMER
176
+ from tradetropy.models import Strategy, FootprintConfig
177
+ from tradetropy.session import SeshSimulatorBase
178
+ from tradetropy.core import TickData, KlineData, parse_timeframe, TIMEFRAME_PRESETS
179
+ from tradetropy.stats import Stats, compute_stats
180
+ from tradetropy.robustness import MonteCarlo, MonteCarloConfig, MonteCarloResult
181
+ from tradetropy.plotting import plot, PlotConfig
182
+ from tradetropy.ta import (
183
+ Indicator, IndicatorPlotConfig, SMA, EMA, MACD, RSI, ATR,
184
+ BollingerBands, VolumeProfile, TickVolumeProfile, RollingVolumeProfile,
185
+ VolumeNode, detect_volume_nodes, ZigZag, ConfirmedPivot, PivotHighLow,
186
+ SwingHL, EqualHL, FairValueGap, OrderBlock, MarketSessions,
187
+ SessionLevels, KillZones, LargeTrades,
188
+ )
@@ -0,0 +1,3 @@
1
+ from tradetropy.backtest.engine import BacktestEngine
2
+ from tradetropy.backtest.pool import PoolBacktestEngine
3
+ from tradetropy.backtest.pool_adapter import PoolEvaluator
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+
5
+ import numpy as np
6
+
7
+ from tradetropy.models.strategy import FeedType
8
+ from tradetropy.core.constants import (
9
+ TICK_COLS,
10
+ OHLC_COLS,
11
+ N_TICK_COLS,
12
+ N_OHLC_COLS,
13
+ _TICK_COL,
14
+ _OHLC_COL,
15
+ )
16
+ from tradetropy.data.data import (
17
+ TickProxy,
18
+ OhlcProxy,
19
+ IndicatorProxy,
20
+ ColumnRef,
21
+ WindowView,
22
+ OhlcDataStore,
23
+ TickDataStore,
24
+ OhlcIndicatorView,
25
+ MultiOhlcIndicatorView,
26
+ MultiBandProxy,
27
+ build_candles_from_ticks,
28
+ )
29
+ from tradetropy.ta.base import Indicator
30
+ from tradetropy.models.strategy import Strategy
31
+ from tradetropy.exceptions import ConfigError
32
+
33
+
34
+ # HELPER: BUILD CANDLES FROM KLINES
35
+
36
+ def _build_candles_from_klines(klines: np.ndarray, interval_ms: int) -> dict:
37
+ '''
38
+ Build OHLC candles from klines data.
39
+
40
+ Constructs a dict for OhlcDataStore from klines [N x >= 6].
41
+
42
+ When interval_ms matches the data interval, each kline produces
43
+ exactly one candle and its high/low are preserved.
44
+
45
+ When aggregating (e.g. 1m data -> 5m candles), OHLC values are
46
+ correctly propagated:
47
+ open = open of first kline in group
48
+ high = max(highs of group)
49
+ low = min(lows of group)
50
+ close = close of last kline in group
51
+ volume = sum(volumes of group)
52
+
53
+ Args:
54
+ klines (np.ndarray): Input data [N x >= 6]
55
+ interval_ms (int): Target interval in milliseconds
56
+
57
+ Returns:
58
+ dict: Contains closed_candles, tick_to_candle_map, accumulated_per_tick,
59
+ candle_ts_per_tick, prices
60
+
61
+ Note:
62
+ Expected columns: ts(0), open(1), high(2), low(3), close(4), vol(5).
63
+ '''
64
+ from tradetropy.core.constants import N_OHLC_COLS, _OHLC_COL
65
+
66
+ interval_ms = int(interval_ms)
67
+ ts_col = klines[:, 0]
68
+ open_col = klines[:, 1]
69
+ high_col = klines[:, 2]
70
+ low_col = klines[:, 3]
71
+ close_col = klines[:, 4]
72
+ volume_col = klines[:, 5]
73
+ n = len(klines)
74
+
75
+ # ── Step 1: assign each kline to its target candle ──────────────────────
76
+ candle_ts_per_kline = (ts_col // interval_ms) * interval_ms
77
+ is_new_candle = np.r_[True, candle_ts_per_kline[1:] != candle_ts_per_kline[:-1]]
78
+ start_indices = np.where(is_new_candle)[0]
79
+ end_indices = np.r_[start_indices[1:], n]
80
+ count_per_candle = np.diff(np.r_[start_indices, n])
81
+
82
+ n_total_candles = len(start_indices)
83
+ kline_to_candle_map = np.repeat(np.arange(n_total_candles), count_per_candle)
84
+
85
+ # ── Step 2: OHLC accumulators per kline (for partial candle in O(1)) ─────────
86
+ # open per kline = open of the first kline in its group
87
+ open_per_kline = np.repeat(open_col[start_indices], count_per_candle)
88
+
89
+ # high accumulated up to each kline within its group
90
+ high_acum = high_col.copy()
91
+ low_acum = low_col.copy()
92
+ for s, e in zip(start_indices, end_indices):
93
+ np.maximum.accumulate(high_acum[s:e], out=high_acum[s:e])
94
+ np.minimum.accumulate(low_acum[s:e], out=low_acum[s:e])
95
+
96
+ # volume accumulated restarting at each group
97
+ cumsum_vol = np.cumsum(volume_col)
98
+ vol_at_start = cumsum_vol[start_indices] - volume_col[start_indices]
99
+ vol_acum = cumsum_vol - np.repeat(vol_at_start, count_per_candle)
100
+
101
+ # accumulated_per_kline[i] = [open, high, low, vol] up to kline i within its candle
102
+ accumulated_per_kline = np.column_stack(
103
+ [open_per_kline, high_acum, low_acum, vol_acum]
104
+ )
105
+
106
+ # ── Step 3: closed candles (all but the last) ────────────────────────
107
+ close_indices = end_indices - 1
108
+ n_closed_candles = n_total_candles - 1
109
+
110
+ if n_closed_candles > 0:
111
+ idx_c = close_indices[:n_closed_candles]
112
+ closed_candles = np.column_stack(
113
+ [
114
+ candle_ts_per_kline[idx_c], # ts open
115
+ accumulated_per_kline[idx_c, 0], # open
116
+ accumulated_per_kline[idx_c, 1], # high
117
+ accumulated_per_kline[idx_c, 2], # low
118
+ close_col[idx_c], # close (last price of group)
119
+ accumulated_per_kline[idx_c, 3], # volume
120
+ ]
121
+ ).astype(np.float64)
122
+ else:
123
+ closed_candles = np.empty((0, N_OHLC_COLS), dtype=np.float64)
124
+
125
+ return {
126
+ "closed_candles": closed_candles,
127
+ "tick_to_candle_map": kline_to_candle_map,
128
+ "accumulated_per_tick": accumulated_per_kline.astype(np.float64),
129
+ "candle_ts_per_tick": candle_ts_per_kline,
130
+ "prices": close_col,
131
+ }
132
+
133
+
134
+ # MIXIN: BUILDING DATA STORES
135
+
136
+ class _StoreBuilderMixin:
137
+ '''Shared methods for constructing data stores across engines.'''
138
+
139
+ strategy: Strategy
140
+ _tick_stores: dict
141
+ _ohlc_stores: dict
142
+
143
+ def _build_tick_store(
144
+ self, symbol: str, tick_matrix: np.ndarray
145
+ ) -> TickDataStore:
146
+ '''
147
+ Build TickDataStore for a specific symbol.
148
+
149
+ Filters indicator definitions by exact symbol (multi-symbol fix).
150
+ Supports multi-source indicators (list[ColumnRef]) and multi-band
151
+ (n_outputs > 1) patterns.
152
+
153
+ Args:
154
+ symbol (str): Trading symbol
155
+ tick_matrix (np.ndarray): Tick data matrix
156
+
157
+ Returns:
158
+ TickDataStore: Store with computed indicators
159
+ '''
160
+ col_index: dict[str, int] = dict(zip(TICK_COLS, range(N_TICK_COLS)))
161
+ cols_ind: list[np.ndarray] = []
162
+
163
+ for defn in self.strategy._indicator_defs:
164
+ source = defn["source"]
165
+ if not isinstance(source.proxy, TickProxy):
166
+ continue
167
+ if source.symbol != symbol:
168
+ continue
169
+
170
+ indicator = defn["indicator"]
171
+ sources = defn.get("sources", [source])
172
+ multi_source = defn.get("multi_source", False)
173
+ multi_band = defn.get("multi_band", False)
174
+
175
+ # Build source array
176
+ if multi_source:
177
+ source_arr = np.column_stack([
178
+ tick_matrix[:, _TICK_COL[f._col_name]] for f in sources
179
+ ])
180
+ else:
181
+ src_col = _TICK_COL[source._col_name]
182
+ source_arr = tick_matrix[:, src_col]
183
+
184
+ result = indicator.calculate(source_arr)
185
+
186
+ if multi_band:
187
+ K = indicator.n_outputs
188
+ col_names = []
189
+ for k in range(K):
190
+ band_col_name = f"{indicator.col_name(symbol, source._col_name)}_b{k}"
191
+ col_index[band_col_name] = N_TICK_COLS + len(cols_ind)
192
+ col_names.append(band_col_name)
193
+ cols_ind.append(result[k])
194
+ defn["col_names"] = col_names
195
+ defn["col_name"] = col_names[0]
196
+ defn["en_tick_store"] = True
197
+ defn["tick_symbol"] = symbol
198
+ else:
199
+ col_name = indicator.col_name(symbol, source._col_name)
200
+ col_index[col_name] = N_TICK_COLS + len(cols_ind)
201
+ defn["col_name"] = col_name
202
+ defn["en_tick_store"] = True
203
+ defn["tick_symbol"] = symbol
204
+ cols_ind.append(result.ravel() if result.ndim > 1 else result)
205
+
206
+ if cols_ind:
207
+ matrix = np.ascontiguousarray(
208
+ np.hstack([tick_matrix, np.column_stack(cols_ind)])
209
+ )
210
+ else:
211
+ matrix = np.ascontiguousarray(tick_matrix)
212
+
213
+ return TickDataStore(matrix, col_index)
214
+
215
+ def _build_ohlc_stores(self, data: dict, feed_type: FeedType = "tick") -> dict:
216
+ '''
217
+ Build OhlcDataStores for all OhlcProxy instances.
218
+
219
+ Args:
220
+ data (dict): Symbol -> data matrix mapping
221
+ feed_type (FeedType): 'tick' or 'kline'
222
+
223
+ Returns:
224
+ dict: id(proxy) -> OhlcDataStore mapping
225
+
226
+ Note:
227
+ In tick mode: data[symbol] are ticks [N x 7], aggregated by interval.
228
+ In kline mode: data[symbol] are klines [N x 7], interval from KlineData.
229
+ '''
230
+ ohlc_stores = {}
231
+
232
+ # Group indicator_defs by source proxy -- O(n_indicators) once.
233
+ # Avoids the O(proxies * indicators) loop that existed before.
234
+ inds_by_proxy: dict[int, list] = defaultdict(list)
235
+ for defn in self.strategy._indicator_defs:
236
+ source = defn["source"]
237
+ if isinstance(source.proxy, OhlcProxy):
238
+ inds_by_proxy[id(source.proxy)].append(defn)
239
+
240
+ for ohlc_proxy in self.strategy._ohlc_proxies:
241
+ symbol = ohlc_proxy.symbol
242
+ interval = ohlc_proxy.interval_ms
243
+ arr = data[symbol].astype(np.float64)
244
+
245
+ if feed_type == "tick":
246
+ timestamps = arr[:, _TICK_COL["ts"]]
247
+ prices = arr[:, _TICK_COL["price"]]
248
+ volumes = arr[:, _TICK_COL["volume"]]
249
+ candle_result = build_candles_from_ticks(
250
+ timestamps, prices, volumes, interval
251
+ )
252
+ else:
253
+ # kline mode: treat each row as a "tick" with price=close
254
+ candle_result = _build_candles_from_klines(arr, interval)
255
+
256
+ closed_candles = candle_result["closed_candles"]
257
+ tick_to_candle_map = candle_result["tick_to_candle_map"]
258
+ accumulated_per_tick = candle_result["accumulated_per_tick"]
259
+ candle_ts_per_tick = candle_result["candle_ts_per_tick"]
260
+ prices_per_row = candle_result["prices"]
261
+
262
+ col_index: dict[str, int] = dict(zip(OHLC_COLS, range(N_OHLC_COLS)))
263
+ cols_ind: list[np.ndarray] = []
264
+
265
+ # Only indicators from this proxy -- O(n_inds_of_this_proxy).
266
+ # Dedup identical indicators (same class+params on the same source
267
+ # column): compute once and let every duplicate share the column,
268
+ # instead of recomputing and appending an identical column. Keyed by
269
+ # (col_name, source cols, multi_band); col_name encodes
270
+ # class+length+symbol and the source cols disambiguate same-name
271
+ # indicators fed different inputs.
272
+ _seen_ohlc: dict = {}
273
+ for defn in inds_by_proxy[id(ohlc_proxy)]:
274
+ source = defn["source"]
275
+ sources = defn.get("sources", [source])
276
+ indicator = defn["indicator"]
277
+ multi_source = defn.get("multi_source", False)
278
+ multi_band = defn.get("multi_band", False)
279
+
280
+ _base_name = indicator.col_name(symbol, source._col_name)
281
+ _src_key = (
282
+ tuple(_OHLC_COL[f._col_name] for f in sources)
283
+ if multi_source
284
+ else (_OHLC_COL[source._col_name],)
285
+ )
286
+ _dedup_key = (_base_name, _src_key, multi_band)
287
+ _prev = _seen_ohlc.get(_dedup_key)
288
+ if _prev is not None:
289
+ defn["col_name"] = _prev["col_name"]
290
+ defn["en_tick_store"] = False
291
+ defn["ohlc_proxy"] = ohlc_proxy
292
+ defn["src_col_idx"] = _prev["src_col_idx"]
293
+ if multi_band:
294
+ defn["col_names"] = _prev["col_names"]
295
+ continue
296
+
297
+ # Build source array
298
+ if multi_source:
299
+ if len(closed_candles) > 0:
300
+ source_arr = np.column_stack([
301
+ closed_candles[:, _OHLC_COL[f._col_name]] for f in sources
302
+ ])
303
+ else:
304
+ source_arr = np.empty((0, len(sources)), dtype=np.float64)
305
+ else:
306
+ src_col = _OHLC_COL[source._col_name]
307
+ source_arr = (
308
+ closed_candles[:, src_col]
309
+ if len(closed_candles) > 0
310
+ else np.array([], dtype=np.float64)
311
+ )
312
+
313
+ # Single full-history pass (precomputed once for all closed
314
+ # bars). Backtest/replay parity for recursive indicators (RSI,
315
+ # MACD) is achieved by feeding the replay the same candle
316
+ # history via warmup (it reconstructs a full window_size of
317
+ # candles), not by windowing the backtest precompute. This keeps
318
+ # the backtest at its original O(N) precompute speed.
319
+ result = indicator.calculate(source_arr)
320
+
321
+ if multi_band:
322
+ K = indicator.n_outputs
323
+ # region XXX debug
324
+ n_real_bands = result.shape[0] if result.ndim > 1 else 1
325
+ if K != n_real_bands:
326
+ raise ConfigError(
327
+ f"Indicator '{type(indicator).__name__}': n_outputs={K} but "
328
+ f"calculate returned {n_real_bands} rows. "
329
+ f"Check that n_outputs matches the calculate output shape."
330
+ )
331
+ #endregion
332
+ col_names = []
333
+ src_idxs = (
334
+ [_OHLC_COL[f._col_name] for f in sources]
335
+ if multi_source
336
+ else _OHLC_COL[source._col_name]
337
+ )
338
+ for k in range(K):
339
+ cname = f"{indicator.col_name(symbol, source._col_name)}_b{k}"
340
+ col_index[cname] = N_OHLC_COLS + len(cols_ind)
341
+ col_names.append(cname)
342
+ band_data = result[k] if len(result.shape) > 1 else result
343
+ cols_ind.append(band_data)
344
+ defn["col_names"] = col_names
345
+ defn["col_name"] = col_names[0]
346
+ defn["en_tick_store"] = False
347
+ defn["ohlc_proxy"] = ohlc_proxy
348
+ defn["src_col_idx"] = src_idxs
349
+ _seen_ohlc[_dedup_key] = {
350
+ "col_name": col_names[0],
351
+ "col_names": col_names,
352
+ "src_col_idx": src_idxs,
353
+ }
354
+ else:
355
+ col_name = indicator.col_name(symbol, source._col_name)
356
+ col_index[col_name] = N_OHLC_COLS + len(cols_ind)
357
+ defn["col_name"] = col_name
358
+ defn["en_tick_store"] = False
359
+ defn["ohlc_proxy"] = ohlc_proxy
360
+ defn["src_col_idx"] = (
361
+ [_OHLC_COL[f._col_name] for f in sources]
362
+ if multi_source
363
+ else _OHLC_COL[source._col_name]
364
+ )
365
+ cols_ind.append(result.ravel() if result.ndim > 1 else result)
366
+ _seen_ohlc[_dedup_key] = {
367
+ "col_name": col_name,
368
+ "src_col_idx": defn["src_col_idx"],
369
+ }
370
+
371
+ if len(closed_candles) > 0 and cols_ind:
372
+ ohlc_matrix = np.ascontiguousarray(
373
+ np.hstack([closed_candles, np.column_stack(cols_ind)])
374
+ )
375
+ elif len(closed_candles) > 0:
376
+ ohlc_matrix = np.ascontiguousarray(closed_candles)
377
+ else:
378
+ n_cols = N_OHLC_COLS + len(cols_ind)
379
+ ohlc_matrix = np.empty((0, n_cols), dtype=np.float64)
380
+
381
+ ohlc_stores[id(ohlc_proxy)] = OhlcDataStore(
382
+ matrix=ohlc_matrix,
383
+ col_index=col_index,
384
+ tick_to_candle_mapping=tick_to_candle_map,
385
+ accumulated_by_tick=accumulated_per_tick,
386
+ ts_candle_by_tick=candle_ts_per_tick,
387
+ prices_per_tick=prices_per_row,
388
+ interval_ms=interval,
389
+ symbol=symbol,
390
+ kline_mode=(feed_type == "kline"),
391
+ )
392
+
393
+ return ohlc_stores
394
+
395
+ def _connect_proxies(self, tick_stores: dict, ohlc_stores: dict):
396
+ '''Connect each proxy to its data store.'''
397
+ for tp in self.strategy._tick_proxies:
398
+ store = tick_stores.get(tp.symbol)
399
+ if store is not None:
400
+ tp._connect_backtest(store)
401
+
402
+ for op in self.strategy._ohlc_proxies:
403
+ op._connect_backtest(ohlc_stores[id(op)])
404
+
405
+ for defn in self.strategy._indicator_defs:
406
+ proxy: "IndicatorProxy | MultiBandProxy" = defn["proxy"]
407
+ indicator: Indicator = defn["indicator"]
408
+ source: ColumnRef = defn["source"]
409
+ multi_band: bool = defn.get("multi_band", False)
410
+
411
+ if defn.get("en_tick_store", True) and isinstance(source.proxy, TickProxy):
412
+ # -- Tick store ------------------------------------------------
413
+ tick_store = tick_stores[defn["tick_symbol"]]
414
+ size = source.proxy._window_size
415
+
416
+ if multi_band:
417
+ col_names = defn["col_names"]
418
+ for k, cname in enumerate(col_names):
419
+ col_idx = tick_store.col_index[cname]
420
+ view = WindowView(col_idx=col_idx, size=size, tick_store=tick_store)
421
+ proxy._connect_band(k, view)
422
+ else:
423
+ col_idx = tick_store.col_index[defn["col_name"]]
424
+ view = WindowView(col_idx=col_idx, size=size, tick_store=tick_store)
425
+ proxy._connect(view)
426
+ else:
427
+ # -- OHLC store ------------------------------------------------
428
+ ohlc_proxy: OhlcProxy = defn["ohlc_proxy"]
429
+ ohlc_store: OhlcDataStore = ohlc_stores[id(ohlc_proxy)]
430
+ src_col_idx = defn["src_col_idx"]
431
+ size = ohlc_proxy._window_size
432
+
433
+ if multi_band:
434
+ col_names = defn["col_names"]
435
+ ind_col_idxs = [ohlc_store.col_index[n] for n in col_names]
436
+ shared_cache = {}
437
+ for k in range(len(col_names)):
438
+ view = MultiOhlcIndicatorView(
439
+ ohlc_store=ohlc_store,
440
+ indicator=indicator,
441
+ ind_col_idxs=ind_col_idxs,
442
+ src_col_idx=src_col_idx,
443
+ band_idx=k,
444
+ size=size,
445
+ shared_cache=shared_cache,
446
+ )
447
+ proxy._connect_band(k, view)
448
+ else:
449
+ ind_col_idx = ohlc_store.col_index[defn["col_name"]]
450
+ view = OhlcIndicatorView(
451
+ ohlc_store=ohlc_store,
452
+ indicator=indicator,
453
+ ind_col_idx=ind_col_idx,
454
+ src_col_idx=src_col_idx,
455
+ size=size,
456
+ )
457
+ proxy._connect(view)
458
+ # Wire the single-frame scalar read fast path for the common
459
+ # kline-mode read (self.ind[-1] / [-k]); non-kline (tick)
460
+ # backtests keep the view path. See IndicatorProxy.
461
+ if ohlc_store.kline_mode and isinstance(src_col_idx, int):
462
+ proxy._enable_flat_kline(
463
+ ohlc_store, ind_col_idx, size,
464
+ getattr(indicator, "warmup_factor", 1) == 1,
465
+ )
466
+
467
+ def _get_ind_def_for_proxy(self, proxy) -> "dict | None":
468
+ '''Find the indicator definition corresponding to a proxy.'''
469
+ for defn in self.strategy._indicator_defs:
470
+ if defn["proxy"] is proxy:
471
+ return defn
472
+ return None
473
+
474
+ def _build_pattern_stores(self, ohlc_stores: dict) -> None:
475
+ '''Build pattern stores from OHLC data.'''
476
+ # No patterns -> nothing to build. Guarding here also avoids importing
477
+ # the pattern DSL, which is absent from lower-tier (gated) builds.
478
+ if not getattr(self.strategy, "_pattern_matcher_defs", None):
479
+ return
480
+ from tradetropy.ta.pattern._builder import iter_pattern_matcher_defs
481
+ from tradetropy.ta.pattern.sequence import FrozenPivotSequence
482
+ from tradetropy.ta.pattern.store import PatternStore
483
+
484
+ for pm_def, _, ohlc_proxy, symbol, base_cols, decorator_cols, tag_decoders \
485
+ in iter_pattern_matcher_defs(self.strategy, self._get_ind_def_for_proxy):
486
+
487
+ ohlc_store = ohlc_stores[id(ohlc_proxy)]
488
+ sequence = FrozenPivotSequence.from_ohlc_store(
489
+ ohlc_store = ohlc_store,
490
+ base_col_names = base_cols,
491
+ decorator_cols = decorator_cols,
492
+ tag_decoders = tag_decoders,
493
+ )
494
+ store = PatternStore(sequence, pm_def.pattern)
495
+ pm_def.proxy._connect_backtest(store, ohlc_store=ohlc_store)