topstep-backtest 0.1.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.
- topstep_backtest/__init__.py +43 -0
- topstep_backtest/clock/__init__.py +1 -0
- topstep_backtest/clock/live_clock.py +82 -0
- topstep_backtest/clock/test_clock.py +133 -0
- topstep_backtest/core/__init__.py +1 -0
- topstep_backtest/core/ids.py +23 -0
- topstep_backtest/core/instruments.py +167 -0
- topstep_backtest/core/money.py +160 -0
- topstep_backtest/core/time.py +125 -0
- topstep_backtest/data/__init__.py +1 -0
- topstep_backtest/data/clean.py +86 -0
- topstep_backtest/data/feed.py +56 -0
- topstep_backtest/data/synthetic.py +137 -0
- topstep_backtest/data/validator.py +215 -0
- topstep_backtest/data/wrangler.py +306 -0
- topstep_backtest/engine/__init__.py +1 -0
- topstep_backtest/engine/backtest.py +209 -0
- topstep_backtest/execution/__init__.py +1 -0
- topstep_backtest/execution/rejections.py +53 -0
- topstep_backtest/execution/sim_broker.py +1436 -0
- topstep_backtest/fills/__init__.py +1 -0
- topstep_backtest/fills/bar_fill.py +268 -0
- topstep_backtest/fills/fees.py +120 -0
- topstep_backtest/fills/path.py +59 -0
- topstep_backtest/harness.py +446 -0
- topstep_backtest/indicators/__init__.py +46 -0
- topstep_backtest/indicators/base.py +57 -0
- topstep_backtest/indicators/library.py +303 -0
- topstep_backtest/indicators/talib_adapter.py +657 -0
- topstep_backtest/metrics/__init__.py +5 -0
- topstep_backtest/metrics/stats.py +153 -0
- topstep_backtest/protocols.py +473 -0
- topstep_backtest/py.typed +0 -0
- topstep_backtest/rules/__init__.py +1 -0
- topstep_backtest/rules/kernel.py +281 -0
- topstep_backtest/rules/params.py +74 -0
- topstep_backtest/strategy/__init__.py +20 -0
- topstep_backtest/strategy/base.py +118 -0
- topstep_backtest/strategy/symbol.py +344 -0
- topstep_backtest/strategy/tracker.py +151 -0
- topstep_backtest-0.1.0.dist-info/METADATA +250 -0
- topstep_backtest-0.1.0.dist-info/RECORD +44 -0
- topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
- topstep_backtest-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""TA-Lib as the single source of every technical indicator.
|
|
2
|
+
|
|
3
|
+
Every indicator in this framework is TA-Lib: bars are buffered as float64
|
|
4
|
+
arrays and handed to ``talib``'s abstract API, which computes the indicator and
|
|
5
|
+
yields the value for the just-closed bar. Nothing here re-implements an
|
|
6
|
+
indicator formula, so there is no second implementation to drift. 152 of
|
|
7
|
+
TA-Lib's 161 functions are wrappable; the other nine are refused at
|
|
8
|
+
construction because they could only fail silently (see ``_probe``,
|
|
9
|
+
``_INDEX_OUTPUT_FUNCTIONS``, and ``_input_slots``).
|
|
10
|
+
|
|
11
|
+
Three properties make a batch C library safe to drive bar-by-bar:
|
|
12
|
+
|
|
13
|
+
**Causal by construction.** ``update(bar)`` appends to a buffer that holds only
|
|
14
|
+
already-closed bars 0…t and reads the LAST element of TA-Lib's output. The
|
|
15
|
+
adapter structurally cannot see bar t+1, so the no-look-ahead invariant in
|
|
16
|
+
AGENTS.md holds for free. Swept across every wrappable function in
|
|
17
|
+
``tests/property/test_indicator_props.py``.
|
|
18
|
+
|
|
19
|
+
**Streaming == batch, bit for bit.** TA-Lib recomputes from index 0 on every
|
|
20
|
+
call, and every function it exposes is causal, so ``FUNC(bars[:t+1])[-1] ==
|
|
21
|
+
FUNC(bars)[t]`` exactly — over the SAME bars. Feeding a prefix cannot produce a
|
|
22
|
+
number that a batch run over those bars would not. (The one class of exception
|
|
23
|
+
is index-valued outputs, which report an offset into the array they were given
|
|
24
|
+
rather than a value; those functions are refused outright — see
|
|
25
|
+
``_INDEX_OUTPUT_FUNCTIONS``.)
|
|
26
|
+
|
|
27
|
+
**Bounded, deterministic history — the parity decision.** The buffer keeps the
|
|
28
|
+
last ``history_bars`` bars, not everything since inception. That is not a
|
|
29
|
+
memory optimisation, it is what makes ``Ema(20)`` the SAME number in sim and
|
|
30
|
+
live: an unbounded buffer would make every value depend on where the series
|
|
31
|
+
happened to start, and a live session (which warms up from a finite history
|
|
32
|
+
fetch) could never reproduce a backtest that began two years earlier. With a
|
|
33
|
+
fixed window the value is a pure function of the last ``history_bars`` bars, so
|
|
34
|
+
preloading exactly ``history_bars`` bars live reproduces the backtest's Decimal
|
|
35
|
+
bit-for-bit.
|
|
36
|
+
|
|
37
|
+
Two consequences of the window that callers must know:
|
|
38
|
+
|
|
39
|
+
*Parity begins at ``history_bars``, not at ``lookback``.* ``ready`` flips as
|
|
40
|
+
soon as a value EXISTS (TA-Lib's lookback), but until ``warm`` is True the
|
|
41
|
+
buffer is still shorter than the window and the value therefore depends on
|
|
42
|
+
where this particular run started. Bars in ``[lookback, warm)`` are cold-start
|
|
43
|
+
dependent. Gate on ``warm`` where exact sim/live agreement matters.
|
|
44
|
+
|
|
45
|
+
*Windowed == unbounded holds for the decaying family, not universally.*
|
|
46
|
+
Recursive functions whose weights decay exponentially (EMA, Wilder RSI/ATR/ADX,
|
|
47
|
+
MACD, DEMA/TEMA, T3, MAMA, HT_TRENDLINE) reach a point past which the truncated
|
|
48
|
+
tail falls below float64 resolution, and ``_STABILITY_FACTOR`` (64x ``lookback``,
|
|
49
|
+
floor ``_MIN_HISTORY``) sits above it — those ARE bit-identical to an unbounded
|
|
50
|
+
run, asserted in ``tests/property/test_indicator_props.py``. Three other classes
|
|
51
|
+
are NOT, by construction rather than by accident:
|
|
52
|
+
|
|
53
|
+
- **Accumulators** (OBV, AD) sum with weight 1.0 forever, so their LEVEL is
|
|
54
|
+
window-relative. Use slope or divergence, never the absolute level.
|
|
55
|
+
- **Running-sum functions** (SMA and anything built on it — STOCH's slowd, CCI,
|
|
56
|
+
MFI, ACCBANDS, BETA, ADOSC) carry float rounding that depends on where the
|
|
57
|
+
sum began, so they sit within an ulp or so of an unbounded run rather than
|
|
58
|
+
exactly on it.
|
|
59
|
+
- **Adaptive smoothers** (KAMA, HT_DCPERIOD) can drop their effective smoothing
|
|
60
|
+
constant far below what ``lookback`` suggests in choppy data, so 64x is not
|
|
61
|
+
always enough; functions whose memory is set by a RATE rather than by a period
|
|
62
|
+
(SAR/SAREXT via ``acceleration``, MAMA via ``slowlimit``) get an explicitly
|
|
63
|
+
derived window instead — see ``_rate_driven_window``.
|
|
64
|
+
|
|
65
|
+
None of this weakens backtest/live parity, which is what the framework actually
|
|
66
|
+
depends on: both sides run the same window over the same bars, so both get the
|
|
67
|
+
same number regardless of which class the function falls into.
|
|
68
|
+
|
|
69
|
+
Values cross into ``Decimal`` at this boundary and are NOT tick-snapped — an
|
|
70
|
+
indicator level is not a tradeable price and rounding it to the grid would be a
|
|
71
|
+
lie. TA-Lib computes in float64, so indicator values are float-precise, not
|
|
72
|
+
Decimal-exact; float arithmetic is deterministic, so reruns remain byte-equal.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import operator
|
|
78
|
+
from decimal import Decimal
|
|
79
|
+
from math import ceil, isfinite
|
|
80
|
+
from typing import TYPE_CHECKING, Final, Protocol, cast
|
|
81
|
+
|
|
82
|
+
import numpy as np
|
|
83
|
+
from talib import abstract as _talib_abstract # pyright: ignore[reportMissingTypeStubs]
|
|
84
|
+
|
|
85
|
+
from .base import NotReadyError
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
89
|
+
|
|
90
|
+
from numpy.typing import NDArray
|
|
91
|
+
|
|
92
|
+
from ..protocols import Bar
|
|
93
|
+
|
|
94
|
+
__all__ = ["TalibIndicator", "TalibLine", "talib_function_names"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# The typed seam over TA-Lib's untyped abstract API
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
|
|
101
|
+
# ``talib.abstract.Function`` is a plain untyped factory (its .pyi does not
|
|
102
|
+
# declare it), so the whole untyped surface is pinned to this Protocol ONCE,
|
|
103
|
+
# here, and every use downstream is fully typed.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _TalibFunction(Protocol):
|
|
107
|
+
"""The slice of ``talib.abstract.Function`` this adapter drives."""
|
|
108
|
+
|
|
109
|
+
lookback: int
|
|
110
|
+
parameters: Mapping[str, int | float]
|
|
111
|
+
input_names: Mapping[str, str | list[str]]
|
|
112
|
+
output_names: list[str]
|
|
113
|
+
|
|
114
|
+
def __call__(
|
|
115
|
+
self, inputs: Mapping[str, NDArray[np.float64]], **params: int | float
|
|
116
|
+
) -> NDArray[np.float64] | list[NDArray[np.float64]]: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_make_function: Final = cast(
|
|
120
|
+
"Callable[[str], _TalibFunction]",
|
|
121
|
+
_talib_abstract.Function, # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_BAR_FIELDS: Final[frozenset[str]] = frozenset({"open", "high", "low", "close", "volume"})
|
|
125
|
+
"""The input names TA-Lib may ask for that a Tier-0 OHLCV bar can supply."""
|
|
126
|
+
|
|
127
|
+
_INDEX_OUTPUT_FUNCTIONS: Final[frozenset[str]] = frozenset({"MAXINDEX", "MININDEX", "MINMAXINDEX"})
|
|
128
|
+
"""Functions whose output is an OFFSET into the array passed in, not a value.
|
|
129
|
+
|
|
130
|
+
A bounded buffer makes that offset meaningless: before the window fills it is
|
|
131
|
+
an index from the series start, after it is a window-relative position that
|
|
132
|
+
shifts every bar. Refused rather than silently reinterpreted.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
_MIN_HISTORY: Final = 512
|
|
136
|
+
"""Window floor: short-period indicators still get a generous tail."""
|
|
137
|
+
|
|
138
|
+
_STABILITY_FACTOR: Final = 64
|
|
139
|
+
"""Window = this many multiples of ``lookback`` — see the module docstring."""
|
|
140
|
+
|
|
141
|
+
_DECAY_TARGET: Final = 40.0
|
|
142
|
+
"""e-folds of decay to retain.
|
|
143
|
+
|
|
144
|
+
``ln(2**-53) ≈ -36.7``, so 40 e-folds puts the truncated tail below float64
|
|
145
|
+
resolution. Used to size the functions whose memory is governed by a RATE
|
|
146
|
+
parameter that ``lookback`` does not reflect at all.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
_KAMA_MIN_HISTORY: Final = 9000
|
|
150
|
+
"""KAMA's smoothing constant floors at ``(2/31)**2 ≈ 0.00416`` in flat/choppy
|
|
151
|
+
data — a regime its ``lookback`` gives no hint of — needing ~8800 bars to decay
|
|
152
|
+
below float64 resolution."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def talib_function_names() -> tuple[str, ...]:
|
|
156
|
+
"""Every TA-Lib function name ``TalibIndicator`` can wrap, sorted."""
|
|
157
|
+
import talib
|
|
158
|
+
|
|
159
|
+
return tuple(sorted(cast("list[str]", talib.get_functions())))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
# Bounded bar history
|
|
164
|
+
# --------------------------------------------------------------------------- #
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _RingBuffer:
|
|
168
|
+
"""Last ``window`` float64 samples, contiguous for a zero-copy TA-Lib call.
|
|
169
|
+
|
|
170
|
+
Writes forward into an over-allocated array and compacts only when it
|
|
171
|
+
fills, so appends are amortised O(1) and ``view()`` is a plain slice — no
|
|
172
|
+
per-bar allocation and no ``np.roll``.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
__slots__ = ("_data", "_n", "_window")
|
|
176
|
+
|
|
177
|
+
def __init__(self, window: int) -> None:
|
|
178
|
+
self._window = window
|
|
179
|
+
self._data: NDArray[np.float64] = np.empty(2 * window, dtype=np.float64)
|
|
180
|
+
self._n = 0
|
|
181
|
+
|
|
182
|
+
def append(self, value: float) -> None:
|
|
183
|
+
if self._n == self._data.shape[0]:
|
|
184
|
+
keep = self._window - 1
|
|
185
|
+
if keep > 0:
|
|
186
|
+
self._data[:keep] = self._data[self._n - keep : self._n]
|
|
187
|
+
self._n = keep
|
|
188
|
+
self._data[self._n] = value
|
|
189
|
+
self._n += 1
|
|
190
|
+
|
|
191
|
+
def view(self) -> NDArray[np.float64]:
|
|
192
|
+
"""The last ``min(seen, window)`` samples, oldest first.
|
|
193
|
+
|
|
194
|
+
A BORROWED view into the backing store: valid only until the next
|
|
195
|
+
``append``, which may recycle exactly this memory during compaction.
|
|
196
|
+
Callers must consume it within the call they pass it to.
|
|
197
|
+
"""
|
|
198
|
+
return self._data[max(0, self._n - self._window) : self._n]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------- #
|
|
202
|
+
# Multi-output views
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TalibLine:
|
|
207
|
+
"""One named output of a multi-output indicator, usable wherever a single
|
|
208
|
+
value series is (notably as a ``Cross`` input).
|
|
209
|
+
|
|
210
|
+
A line does NOT update itself — it reads whatever its owner last computed,
|
|
211
|
+
so registering the OWNER with ``use()`` is what keeps it current.
|
|
212
|
+
``SymbolStrategy.use()`` resolves a ``Cross`` input through ``owner`` for
|
|
213
|
+
exactly this reason.
|
|
214
|
+
|
|
215
|
+
Deliberately NO ``update`` method: that keeps a line from satisfying
|
|
216
|
+
``Indicator``, so ``use(macd.line("macd"))`` — which would register
|
|
217
|
+
something that can never advance and would gate the strategy forever — is a
|
|
218
|
+
type error, and ``use()`` rejects it at runtime too. Register the owner.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
__slots__ = ("_name", "_owner")
|
|
222
|
+
|
|
223
|
+
def __init__(self, owner: TalibIndicator, name: str) -> None:
|
|
224
|
+
self._owner = owner
|
|
225
|
+
self._name = name
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def owner(self) -> TalibIndicator:
|
|
229
|
+
"""The indicator that computes this line (what ``use()`` registers)."""
|
|
230
|
+
return self._owner
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def name(self) -> str:
|
|
234
|
+
"""This line's TA-Lib output name (e.g. ``"macdsignal"``)."""
|
|
235
|
+
return self._name
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def lookback(self) -> int:
|
|
239
|
+
return self._owner.lookback
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def ready(self) -> bool:
|
|
243
|
+
return self._owner.ready
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def warm(self) -> bool:
|
|
247
|
+
return self._owner.warm
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def value(self) -> Decimal:
|
|
251
|
+
return self._owner.get(self._name)
|
|
252
|
+
|
|
253
|
+
def __repr__(self) -> str:
|
|
254
|
+
return f"{self._owner!r}.line({self._name!r})"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------- #
|
|
258
|
+
# The generic adapter
|
|
259
|
+
# --------------------------------------------------------------------------- #
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class TalibIndicator:
|
|
263
|
+
"""Any TA-Lib function, driven bar by bar.
|
|
264
|
+
|
|
265
|
+
``TalibIndicator("RSI", timeperiod=14)`` is the whole API; the named
|
|
266
|
+
classes in ``library.py`` are thin, typed spellings of it. Parameters are
|
|
267
|
+
TA-Lib's own (``timeperiod``, ``fastperiod``, ``nbdevup``, ``matype``, …),
|
|
268
|
+
validated against the function's declared signature AND run past TA-Lib
|
|
269
|
+
once at construction, so a typo or an out-of-range value raises here rather
|
|
270
|
+
than silently using a default or dying mid-backtest.
|
|
271
|
+
|
|
272
|
+
``price`` redirects a single-series function onto another bar field —
|
|
273
|
+
``TalibIndicator("MAX", price="high", timeperiod=20)`` is a rolling high.
|
|
274
|
+
Functions that declare their own multi-field inputs (ATR wants high/low/
|
|
275
|
+
close) reject it.
|
|
276
|
+
|
|
277
|
+
**Thread-safety.** ``talib``'s ``Function`` keeps its configured parameters
|
|
278
|
+
in a ``threading.local``, so a Function configured on one thread silently
|
|
279
|
+
reverts to TA-Lib's DEFAULTS on another — an ``EMA(50)`` built on the main
|
|
280
|
+
thread would quietly compute ``EMA(30)`` inside a worker, with no error.
|
|
281
|
+
This adapter therefore treats the Function object as stateless: parameters
|
|
282
|
+
are held here as plain data and passed on EVERY call, and a redirected
|
|
283
|
+
``price`` is applied by choosing which bar field fills the input slot rather
|
|
284
|
+
than by mutating ``input_names``. Instances are safe to build on one thread
|
|
285
|
+
and drive on another (though a single instance is still not safe to drive
|
|
286
|
+
from two threads at once — it has per-bar state).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
__slots__ = (
|
|
290
|
+
"_buffers",
|
|
291
|
+
"_dirty",
|
|
292
|
+
"_func",
|
|
293
|
+
"_latest",
|
|
294
|
+
"_lookback",
|
|
295
|
+
"_name",
|
|
296
|
+
"_params",
|
|
297
|
+
"_seen",
|
|
298
|
+
"_slots",
|
|
299
|
+
"_window",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
name: str,
|
|
305
|
+
*,
|
|
306
|
+
price: str | None = None,
|
|
307
|
+
history: int | None = None,
|
|
308
|
+
**params: int | float,
|
|
309
|
+
) -> None:
|
|
310
|
+
self._name = name.upper()
|
|
311
|
+
func = _resolve_function(self._name)
|
|
312
|
+
# Assigning `parameters` is what makes `lookback` reflect OUR periods —
|
|
313
|
+
# a fresh Function reports the DEFAULT period's lookback. We then keep
|
|
314
|
+
# the values here as well and re-pass them on every call, because that
|
|
315
|
+
# assignment is thread-local (see the class docstring).
|
|
316
|
+
self._params = _coerce_params(func, self._name, params)
|
|
317
|
+
if self._params:
|
|
318
|
+
func.parameters = self._params
|
|
319
|
+
self._func = func
|
|
320
|
+
self._lookback = func.lookback + 1
|
|
321
|
+
self._slots = _input_slots(func, self._name, price)
|
|
322
|
+
self._window = _resolve_window(self._name, func, self._lookback, history)
|
|
323
|
+
_probe(func, self._name, self._params, self._slots)
|
|
324
|
+
self._buffers = {key: _RingBuffer(self._window) for key, _ in self._slots}
|
|
325
|
+
self._latest: dict[str, float] = {}
|
|
326
|
+
self._seen = 0
|
|
327
|
+
self._dirty = True
|
|
328
|
+
|
|
329
|
+
# -- the Indicator surface ----------------------------------------------
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def lookback(self) -> int:
|
|
333
|
+
"""Bars needed before the first value exists (TA-Lib's lookback + 1)."""
|
|
334
|
+
return self._lookback
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def history_bars(self) -> int:
|
|
338
|
+
"""Bars retained. Preload this many live for bit-exact sim/live parity."""
|
|
339
|
+
return self._window
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def warm(self) -> bool:
|
|
343
|
+
"""True once the buffer is full, i.e. from the first PARITY-exact bar.
|
|
344
|
+
|
|
345
|
+
``ready`` says a value exists; ``warm`` says the value no longer depends
|
|
346
|
+
on where this run started. Gate on this when sim and live must agree
|
|
347
|
+
bit-for-bit (see the module docstring).
|
|
348
|
+
"""
|
|
349
|
+
return self._seen >= self._window
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def outputs(self) -> tuple[str, ...]:
|
|
353
|
+
"""This function's output names, TA-Lib's order (``value`` is the first)."""
|
|
354
|
+
return tuple(self._func.output_names)
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def function_name(self) -> str:
|
|
358
|
+
"""The wrapped TA-Lib function, upper-cased (e.g. ``"MACD"``)."""
|
|
359
|
+
return self._name
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def ready(self) -> bool:
|
|
363
|
+
if self._seen < self._lookback:
|
|
364
|
+
return False
|
|
365
|
+
self._compute()
|
|
366
|
+
return bool(self._latest)
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def value(self) -> Decimal:
|
|
370
|
+
"""The primary (first) output for the most recent bar."""
|
|
371
|
+
return self.get(self._func.output_names[0])
|
|
372
|
+
|
|
373
|
+
def get(self, output: str) -> Decimal:
|
|
374
|
+
"""A named output for the most recent bar."""
|
|
375
|
+
if output not in self._func.output_names:
|
|
376
|
+
raise KeyError(f"{self!r} has no output {output!r}; it has {self.outputs}")
|
|
377
|
+
if self._seen < self._lookback:
|
|
378
|
+
raise NotReadyError(f"{self!r} needs {self._lookback} bars, has seen {self._seen}")
|
|
379
|
+
self._compute()
|
|
380
|
+
if not self._latest:
|
|
381
|
+
raise NotReadyError(
|
|
382
|
+
f"{self!r} has seen {self._seen} bars but produced no finite value for this "
|
|
383
|
+
f"bar — degenerate input for this function, not warmup"
|
|
384
|
+
)
|
|
385
|
+
return _to_decimal(self._latest[output])
|
|
386
|
+
|
|
387
|
+
def line(self, output: str) -> TalibLine:
|
|
388
|
+
"""A ``Cross``-compatible view of one named output."""
|
|
389
|
+
if output not in self._func.output_names:
|
|
390
|
+
raise KeyError(f"{self!r} has no output {output!r}; it has {self.outputs}")
|
|
391
|
+
return TalibLine(self, output)
|
|
392
|
+
|
|
393
|
+
def lines(self) -> Iterator[TalibLine]:
|
|
394
|
+
"""A view per output, in TA-Lib's order."""
|
|
395
|
+
return (TalibLine(self, name) for name in self._func.output_names)
|
|
396
|
+
|
|
397
|
+
def update(self, bar: Bar) -> None:
|
|
398
|
+
for key, field in self._slots:
|
|
399
|
+
self._buffers[key].append(_bar_field(bar, field))
|
|
400
|
+
self._seen += 1
|
|
401
|
+
self._dirty = True
|
|
402
|
+
|
|
403
|
+
# -- the one TA-Lib call -------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def _compute(self) -> None:
|
|
406
|
+
"""Run TA-Lib over the buffered window and latch the last row.
|
|
407
|
+
|
|
408
|
+
Lazy and memoised per bar: a strategy that never reads a value pays
|
|
409
|
+
only for the buffer append, and reading three lines of a MACD costs one
|
|
410
|
+
TA-Lib call, not three. The dirty flag is cleared only AFTER the call
|
|
411
|
+
succeeds, so a raising TA-Lib call is never memoised as a benign
|
|
412
|
+
"no value yet".
|
|
413
|
+
|
|
414
|
+
The arrays handed over are borrowed views into the ring buffers, valid
|
|
415
|
+
only for the duration of this call — never read them back off the
|
|
416
|
+
Function afterwards.
|
|
417
|
+
"""
|
|
418
|
+
if not self._dirty:
|
|
419
|
+
return
|
|
420
|
+
self._latest = {}
|
|
421
|
+
if self._seen < self._lookback:
|
|
422
|
+
self._dirty = False
|
|
423
|
+
return
|
|
424
|
+
raw = self._func(
|
|
425
|
+
{key: buffer.view() for key, buffer in self._buffers.items()}, **self._params
|
|
426
|
+
)
|
|
427
|
+
arrays = raw if isinstance(raw, list) else [raw]
|
|
428
|
+
latest: dict[str, float] = {}
|
|
429
|
+
for name, array in zip(self._func.output_names, arrays, strict=True):
|
|
430
|
+
value = float(array[-1])
|
|
431
|
+
# NaN means "no value for this bar". Usually that is warmup, which
|
|
432
|
+
# the `_seen < _lookback` gate above already covers — but a few
|
|
433
|
+
# functions (IMI on a dead tape where every open == close) divide
|
|
434
|
+
# 0/0 and go non-finite long AFTER their lookback. Either way the
|
|
435
|
+
# value genuinely does not exist, so the whole row is withheld.
|
|
436
|
+
if not isfinite(value):
|
|
437
|
+
self._dirty = False
|
|
438
|
+
return
|
|
439
|
+
latest[name] = value
|
|
440
|
+
self._latest = latest
|
|
441
|
+
self._dirty = False
|
|
442
|
+
|
|
443
|
+
def __repr__(self) -> str:
|
|
444
|
+
parts = [repr(self._name)]
|
|
445
|
+
parts += [f"{key}={value}" for key, value in self._func.parameters.items()]
|
|
446
|
+
return f"TalibIndicator({', '.join(parts)})"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# --------------------------------------------------------------------------- #
|
|
450
|
+
# construction helpers
|
|
451
|
+
# --------------------------------------------------------------------------- #
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _resolve_function(name: str) -> _TalibFunction:
|
|
455
|
+
if name in _INDEX_OUTPUT_FUNCTIONS:
|
|
456
|
+
raise ValueError(
|
|
457
|
+
f"{name} returns an INDEX into the array it is given, not a value; a bounded "
|
|
458
|
+
f"history buffer makes that offset meaningless. Use the value-returning form "
|
|
459
|
+
f"(MAX/MIN/MINMAX) instead."
|
|
460
|
+
)
|
|
461
|
+
try:
|
|
462
|
+
return _make_function(name)
|
|
463
|
+
except Exception as error: # talib raises bare Exception for a bad name
|
|
464
|
+
raise ValueError(f"unknown TA-Lib function {name!r}") from error
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _coerce_params(
|
|
468
|
+
func: _TalibFunction, name: str, params: Mapping[str, int | float]
|
|
469
|
+
) -> dict[str, int | float]:
|
|
470
|
+
"""Validate parameter names and coerce values to TA-Lib's declared types.
|
|
471
|
+
|
|
472
|
+
TA-Lib type-checks int vs float strictly, so each value is converted to
|
|
473
|
+
whatever the function declares. The conversion REFUSES to be lossy: a
|
|
474
|
+
fractional value for an integer parameter (``Sma(14.7)``, ``matype=2.9``)
|
|
475
|
+
is a mistake, and silently truncating it would contradict this class's
|
|
476
|
+
promise that a bad parameter raises here.
|
|
477
|
+
"""
|
|
478
|
+
if not params:
|
|
479
|
+
return {}
|
|
480
|
+
declared = dict(func.parameters)
|
|
481
|
+
unknown = set(params) - set(declared)
|
|
482
|
+
if unknown:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"{name} takes no parameter(s) {sorted(unknown)}; it accepts {sorted(declared)}"
|
|
485
|
+
)
|
|
486
|
+
coerced: dict[str, int | float] = {}
|
|
487
|
+
for key, value in params.items():
|
|
488
|
+
if isinstance(value, bool):
|
|
489
|
+
raise ValueError(f"{name} parameter {key} must be a number, got {value!r}")
|
|
490
|
+
try:
|
|
491
|
+
if isinstance(declared[key], float):
|
|
492
|
+
coerced[key] = float(value)
|
|
493
|
+
elif isinstance(value, float):
|
|
494
|
+
if not value.is_integer():
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"{name} parameter {key} is an integer; got {value!r} "
|
|
497
|
+
f"(truncating it would silently change the indicator)"
|
|
498
|
+
)
|
|
499
|
+
coerced[key] = int(value)
|
|
500
|
+
else:
|
|
501
|
+
coerced[key] = operator.index(value)
|
|
502
|
+
except (TypeError, OverflowError) as error:
|
|
503
|
+
raise ValueError(f"{name} parameter {key}={value!r} is not a valid number") from error
|
|
504
|
+
return coerced
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _input_slots(func: _TalibFunction, name: str, price: str | None) -> tuple[tuple[str, str], ...]:
|
|
508
|
+
"""``(talib input key, bar field)`` pairs, deduplicated in TA-Lib's order.
|
|
509
|
+
|
|
510
|
+
Redirecting ``price`` changes which BAR FIELD fills TA-Lib's input slot
|
|
511
|
+
rather than mutating ``func.input_names`` — that assignment is thread-local
|
|
512
|
+
and would silently revert off-thread (see ``TalibIndicator``'s docstring).
|
|
513
|
+
"""
|
|
514
|
+
declared = func.input_names
|
|
515
|
+
if price is not None:
|
|
516
|
+
if list(declared) != ["price"]:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"price= only applies to single-series functions; {name} takes "
|
|
519
|
+
f"{_describe_inputs(declared)}"
|
|
520
|
+
)
|
|
521
|
+
if price not in _BAR_FIELDS:
|
|
522
|
+
raise ValueError(f"price must be one of {sorted(_BAR_FIELDS)}, got {price!r}")
|
|
523
|
+
return ((cast("str", declared["price"]), price),)
|
|
524
|
+
|
|
525
|
+
slots: list[tuple[str, str]] = []
|
|
526
|
+
for requested in declared.values():
|
|
527
|
+
keys = [requested] if isinstance(requested, str) else list(requested)
|
|
528
|
+
for key in keys:
|
|
529
|
+
if key not in _BAR_FIELDS:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
f"{name} needs a non-bar input {key!r}, which an OHLCV feed cannot supply"
|
|
532
|
+
)
|
|
533
|
+
if all(key != existing for existing, _ in slots):
|
|
534
|
+
slots.append((key, key))
|
|
535
|
+
return tuple(slots)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _rate_driven_window(name: str, params: Mapping[str, int | float]) -> int:
|
|
539
|
+
"""Window for functions whose memory is set by a RATE, not by a period.
|
|
540
|
+
|
|
541
|
+
SAR's ``lookback`` is 2 no matter how small ``acceleration`` is, and MAMA's
|
|
542
|
+
is fixed while ``slowlimit`` sets its actual memory — so the usual
|
|
543
|
+
``64 * lookback`` sizing is blind to them. Derive the window from the rate
|
|
544
|
+
instead: an update of rate ``r`` decays by ``(1 - r)`` per bar, so
|
|
545
|
+
``_DECAY_TARGET / r`` bars puts the tail under float64 resolution.
|
|
546
|
+
"""
|
|
547
|
+
if name == "KAMA":
|
|
548
|
+
return _KAMA_MIN_HISTORY
|
|
549
|
+
rates: list[float] = []
|
|
550
|
+
if name in {"SAR", "SAREXT"}:
|
|
551
|
+
rates = [float(v) for k, v in params.items() if "acceleration" in k and float(v) > 0]
|
|
552
|
+
elif name == "MAMA":
|
|
553
|
+
rates = [float(params.get("slowlimit", 0.05))]
|
|
554
|
+
positive = [r for r in rates if r > 0]
|
|
555
|
+
return ceil(_DECAY_TARGET / min(positive)) if positive else 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _resolve_window(name: str, func: _TalibFunction, lookback: int, history: int | None) -> int:
|
|
559
|
+
if history is not None:
|
|
560
|
+
try:
|
|
561
|
+
history = operator.index(history)
|
|
562
|
+
except TypeError as error:
|
|
563
|
+
raise ValueError(f"history must be an integer, got {history!r}") from error
|
|
564
|
+
if history < lookback:
|
|
565
|
+
raise ValueError(
|
|
566
|
+
f"history={history} is below {name}'s lookback of {lookback} bars; "
|
|
567
|
+
f"the indicator could never become ready"
|
|
568
|
+
)
|
|
569
|
+
return history
|
|
570
|
+
return max(
|
|
571
|
+
_MIN_HISTORY,
|
|
572
|
+
_STABILITY_FACTOR * lookback,
|
|
573
|
+
_rate_driven_window(name, func.parameters),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _probe(
|
|
578
|
+
func: _TalibFunction,
|
|
579
|
+
name: str,
|
|
580
|
+
params: Mapping[str, int | float],
|
|
581
|
+
slots: tuple[tuple[str, str], ...],
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Run the configured function once on synthetic bars, at construction.
|
|
584
|
+
|
|
585
|
+
Catches two failure modes that would otherwise surface far away:
|
|
586
|
+
|
|
587
|
+
*Out-of-range parameters.* TA-Lib validates ranges in C and raises only
|
|
588
|
+
when it actually runs, and each function's limits differ (``EMA`` accepts
|
|
589
|
+
``timeperiod=1``, ``RSI`` and ``STDDEV`` need 2, …) with nothing in the
|
|
590
|
+
abstract API exposing them. Rather than hard-code a table that would drift
|
|
591
|
+
from the library, ask the library.
|
|
592
|
+
|
|
593
|
+
*Functions that can never produce a finite value at futures price levels.*
|
|
594
|
+
``EXP``/``COSH``/``SINH`` overflow and ``ACOS``/``ASIN`` are out of domain
|
|
595
|
+
once prices leave [-1, 1], and a permanently non-finite indicator would
|
|
596
|
+
read as "not ready" forever — ``SymbolStrategy`` would silently swallow
|
|
597
|
+
every bar and ``on_bar`` would never fire. So the probe uses a
|
|
598
|
+
PRICE-REALISTIC series and rejects a non-finite result, rather than merely
|
|
599
|
+
checking that the call did not raise.
|
|
600
|
+
"""
|
|
601
|
+
length = max(func.lookback + 1, 2)
|
|
602
|
+
steps = np.arange(length, dtype=np.float64)
|
|
603
|
+
close = 5000.0 + steps + 25.0 * np.sin(steps / 7.0)
|
|
604
|
+
inputs = {
|
|
605
|
+
"open": close - 0.5,
|
|
606
|
+
"high": close + 1.5,
|
|
607
|
+
"low": close - 1.5,
|
|
608
|
+
"close": close,
|
|
609
|
+
"volume": 1000.0 + 10.0 * (steps % 7),
|
|
610
|
+
}
|
|
611
|
+
probe_inputs = {key: inputs[field] for key, field in slots}
|
|
612
|
+
shown = ", ".join(f"{key}={value}" for key, value in params.items())
|
|
613
|
+
try:
|
|
614
|
+
raw = func(probe_inputs, **params)
|
|
615
|
+
except Exception as error:
|
|
616
|
+
raise ValueError(f"TA-Lib rejected {name}({shown}): {error}") from error
|
|
617
|
+
arrays = raw if isinstance(raw, list) else [raw]
|
|
618
|
+
if not all(isfinite(float(array[-1])) for array in arrays):
|
|
619
|
+
raise ValueError(
|
|
620
|
+
f"{name}({shown}) produces no finite value at futures price levels "
|
|
621
|
+
f"(~5000), so it could never become ready; it is not usable on price bars"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# --------------------------------------------------------------------------- #
|
|
626
|
+
# helpers
|
|
627
|
+
# --------------------------------------------------------------------------- #
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _describe_inputs(inputs: Mapping[str, str | list[str]]) -> str:
|
|
631
|
+
parts: list[str] = []
|
|
632
|
+
for requested in inputs.values():
|
|
633
|
+
parts.extend([requested] if isinstance(requested, str) else requested)
|
|
634
|
+
return "/".join(parts)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _bar_field(bar: Bar, field: str) -> float:
|
|
638
|
+
if field == "open":
|
|
639
|
+
return float(bar.open)
|
|
640
|
+
if field == "high":
|
|
641
|
+
return float(bar.high)
|
|
642
|
+
if field == "low":
|
|
643
|
+
return float(bar.low)
|
|
644
|
+
if field == "close":
|
|
645
|
+
return float(bar.close)
|
|
646
|
+
return float(bar.volume)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _to_decimal(value: float) -> Decimal:
|
|
650
|
+
"""float64 -> Decimal at the library boundary.
|
|
651
|
+
|
|
652
|
+
Via ``repr`` (shortest round-tripping form), so ``Decimal`` shows the
|
|
653
|
+
number a reader would recognise rather than the exact binary expansion.
|
|
654
|
+
Distinct finite floats round-trip to distinct Decimal strings — signed
|
|
655
|
+
zeros aside, which keep their sign but compare equal, as they do as floats.
|
|
656
|
+
"""
|
|
657
|
+
return Decimal(repr(value))
|