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.
Files changed (44) hide show
  1. topstep_backtest/__init__.py +43 -0
  2. topstep_backtest/clock/__init__.py +1 -0
  3. topstep_backtest/clock/live_clock.py +82 -0
  4. topstep_backtest/clock/test_clock.py +133 -0
  5. topstep_backtest/core/__init__.py +1 -0
  6. topstep_backtest/core/ids.py +23 -0
  7. topstep_backtest/core/instruments.py +167 -0
  8. topstep_backtest/core/money.py +160 -0
  9. topstep_backtest/core/time.py +125 -0
  10. topstep_backtest/data/__init__.py +1 -0
  11. topstep_backtest/data/clean.py +86 -0
  12. topstep_backtest/data/feed.py +56 -0
  13. topstep_backtest/data/synthetic.py +137 -0
  14. topstep_backtest/data/validator.py +215 -0
  15. topstep_backtest/data/wrangler.py +306 -0
  16. topstep_backtest/engine/__init__.py +1 -0
  17. topstep_backtest/engine/backtest.py +209 -0
  18. topstep_backtest/execution/__init__.py +1 -0
  19. topstep_backtest/execution/rejections.py +53 -0
  20. topstep_backtest/execution/sim_broker.py +1436 -0
  21. topstep_backtest/fills/__init__.py +1 -0
  22. topstep_backtest/fills/bar_fill.py +268 -0
  23. topstep_backtest/fills/fees.py +120 -0
  24. topstep_backtest/fills/path.py +59 -0
  25. topstep_backtest/harness.py +446 -0
  26. topstep_backtest/indicators/__init__.py +46 -0
  27. topstep_backtest/indicators/base.py +57 -0
  28. topstep_backtest/indicators/library.py +303 -0
  29. topstep_backtest/indicators/talib_adapter.py +657 -0
  30. topstep_backtest/metrics/__init__.py +5 -0
  31. topstep_backtest/metrics/stats.py +153 -0
  32. topstep_backtest/protocols.py +473 -0
  33. topstep_backtest/py.typed +0 -0
  34. topstep_backtest/rules/__init__.py +1 -0
  35. topstep_backtest/rules/kernel.py +281 -0
  36. topstep_backtest/rules/params.py +74 -0
  37. topstep_backtest/strategy/__init__.py +20 -0
  38. topstep_backtest/strategy/base.py +118 -0
  39. topstep_backtest/strategy/symbol.py +344 -0
  40. topstep_backtest/strategy/tracker.py +151 -0
  41. topstep_backtest-0.1.0.dist-info/METADATA +250 -0
  42. topstep_backtest-0.1.0.dist-info/RECORD +44 -0
  43. topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
  44. 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))
@@ -0,0 +1,5 @@
1
+ """topstep_backtest.metrics"""
2
+
3
+ from .stats import SummaryStats, compute_summary
4
+
5
+ __all__ = ["SummaryStats", "compute_summary"]