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,446 @@
1
+ """Two-line backtest assembly: ``Backtest(bars, strategy).run()`` -> ``Report``.
2
+
3
+ Sim-side convenience ONLY — the facade wires the exact same components a
4
+ hand-written main would (feed -> broker -> engine) and changes no semantics:
5
+ ``Report.result`` is the engine's frozen ``BacktestResult``, byte-identical to
6
+ a hand-wired run over the same inputs. What the facade adds is assembly
7
+ correctness (docs/STRATEGY_API.md §§3-4):
8
+
9
+ * ONE ``TestClock`` shared by broker and engine. A second clock stuck at 0
10
+ would stamp every order ``accepted_ts=0`` (eligible for the CURRENT bar —
11
+ silent look-ahead) and run session checks in 1970; the facade makes that
12
+ footgun unbuildable.
13
+ * Instruments derived from the feed's contract ids: the broker registers
14
+ instruments at construction only, and a bar for an unregistered contract is
15
+ a mid-run ``KeyError``.
16
+ * Strict data validation by default: each contract's bars are checked with
17
+ ITS spec and any ERROR finding refuses to run (``validate=False`` skips;
18
+ the feed's ordering invariant is enforced regardless).
19
+ * Real economics always: ``combine_params(account)`` and ``TopstepFees``.
20
+ backtesting.py's economics/semantics knobs (``cash=``, ``commission=``,
21
+ ``trade_on_close=``, ...) are rejected with the project's alternative named.
22
+
23
+ Engine, broker, kernel, and strategy state are single-use, so each
24
+ ``Backtest`` runs exactly once; build a fresh ``Backtest`` (with a fresh
25
+ strategy instance) for another run.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ from decimal import Decimal
32
+ from importlib.metadata import PackageNotFoundError
33
+ from importlib.metadata import version as _dist_version
34
+ from typing import TYPE_CHECKING, Any, Literal
35
+
36
+ from .clock.test_clock import TestClock
37
+ from .core.instruments import SPECS, spec_for_symbol, symbol_of_contract_id
38
+ from .data.feed import ListBarFeed
39
+ from .data.validator import INFO_CODES, validate_bars
40
+ from .data.wrangler import bars_from_dataframe
41
+ from .engine.backtest import BacktestEngine
42
+ from .execution.sim_broker import SimBroker
43
+ from .fills.bar_fill import BarFillModel
44
+ from .fills.fees import TopstepFees
45
+ from .metrics.stats import SummaryStats, compute_summary
46
+ from .rules.kernel import CombineKernel
47
+ from .rules.params import AccountSize, combine_params
48
+ from .strategy.symbol import SymbolStrategy
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Sequence
52
+
53
+ from topstep_sdk import AggregateBarUnit, HalfTradeModel
54
+
55
+ from .core.instruments import InstrumentSpec
56
+ from .data.validator import ValidationIssue
57
+ from .engine.backtest import BacktestResult
58
+ from .execution.sim_broker import SimBrokerConfig
59
+ from .fills.bar_fill import BarFillConfig
60
+ from .protocols import Bar
61
+ from .rules.params import CombineParams
62
+ from .strategy.base import Strategy
63
+
64
+ __all__ = ["Backtest", "DataValidationError", "Report"]
65
+
66
+ _CENT = Decimal("0.01")
67
+
68
+
69
+ def _engine_version() -> str:
70
+ """The installed package version, or a dev marker in a bare source tree."""
71
+ try:
72
+ return _dist_version("topstep-backtest")
73
+ except PackageNotFoundError: # pragma: no cover - source tree without install
74
+ return "0.0.0.dev0"
75
+
76
+
77
+ # Stamped on every rendered Report. A verdict line gets screenshotted and shared
78
+ # out of context, so it has to carry its own provenance and caveat.
79
+ _PROVENANCE = (
80
+ f"topstep-backtest {_engine_version()} — unofficial simulation, not affiliated "
81
+ "with Topstep. Rule and fee constants are cited config, NOT calibrated against "
82
+ "a live account (docs/topstep-rules.md §9): treat the verdict as a diagnostic, "
83
+ "not an authoritative pass/fail."
84
+ )
85
+
86
+ # backtesting.py knobs the §4 ledger REJECTS, each with the alternative named.
87
+ _REJECTED_KNOBS: dict[str, str] = {
88
+ "cash": (
89
+ "account economics are fixed by the Combine — pick "
90
+ "account=AccountSize.S50K/S100K/S150K instead"
91
+ ),
92
+ "commission": (
93
+ "fees are the real Topstep schedule (fills.fees.TopstepFees), applied "
94
+ "always; they are not a per-trade knob. To correct a rate against your "
95
+ "own blotter, pass the whole schedule: "
96
+ "fee_model=TopstepFees(overrides={...})"
97
+ ),
98
+ "margin": (
99
+ "there is no margin model — the Combine's MLL floor and position cap "
100
+ "(combine_params) are the risk limits"
101
+ ),
102
+ "spread": (
103
+ "execution costs come from the Tier-0 bar fill model "
104
+ "(fills.bar_fill.BarFillConfig slippage ticks), not a synthetic spread"
105
+ ),
106
+ "trade_on_close": (
107
+ "filling at the decided bar's close is the exact look-ahead the "
108
+ "accepted_ts firewall forbids; orders fill from the NEXT bar"
109
+ ),
110
+ "hedging": "the venue nets positions per contract — hedged positions cannot exist",
111
+ "exclusive_orders": (
112
+ "hidden auto-close orders would mutate the strategy's intent sequence "
113
+ "(the parity gate); make reversals explicit awaits"
114
+ ),
115
+ "finalize_trades": (
116
+ "end-of-session positions are governed by the 16:10 ET flatten rule, never a stats toggle"
117
+ ),
118
+ }
119
+
120
+
121
+ def _refuse_unknown_kwargs(rejected: dict[str, object]) -> None:
122
+ for name in rejected:
123
+ alternative = _REJECTED_KNOBS.get(name)
124
+ if alternative is not None:
125
+ raise ValueError(f"Backtest() does not support {name!r}: {alternative}")
126
+ if rejected:
127
+ names = ", ".join(repr(name) for name in rejected)
128
+ raise TypeError(f"Backtest() got unexpected keyword argument(s) {names}")
129
+
130
+
131
+ def _refuse_strategy_class(strategy: object) -> None:
132
+ # §4 REJECT: passing the strategy CLASS (backtesting.py's class+attr
133
+ # injection defeats typed constructor parameters). Without this guard the
134
+ # mistake surfaces as an unrelated TypeError deep inside engine.run().
135
+ if isinstance(strategy, type):
136
+ raise TypeError(
137
+ f"Backtest() takes a strategy INSTANCE, not the class "
138
+ f"{strategy.__name__!r} — construct it with its typed parameters, "
139
+ f'e.g. Backtest(bars, {strategy.__name__}("CON.F.US.MNQ.U26")) '
140
+ "(docs/STRATEGY_API.md §4)"
141
+ )
142
+
143
+
144
+ def _spec_for_contract_id(contract_id: str) -> InstrumentSpec:
145
+ symbol = symbol_of_contract_id(contract_id)
146
+ try:
147
+ return spec_for_symbol(symbol)
148
+ except KeyError:
149
+ raise ValueError(
150
+ f"unknown product {symbol!r} (from contract id {contract_id!r}); "
151
+ f"built-in specs cover {sorted(SPECS)}"
152
+ ) from None
153
+
154
+
155
+ class DataValidationError(ValueError):
156
+ """Bar data failed validation; ``issues`` carries every ERROR finding."""
157
+
158
+ def __init__(self, issues: tuple[ValidationIssue, ...]) -> None:
159
+ self.issues = issues
160
+ shown = [f"[{issue.code}] {issue.message}" for issue in issues[:20]]
161
+ if len(issues) > 20:
162
+ shown.append(f"... and {len(issues) - 20} more")
163
+ super().__init__(
164
+ "bar data failed validation (pass validate=False to run anyway):\n "
165
+ + "\n ".join(shown)
166
+ )
167
+
168
+
169
+ def _money(value: Decimal) -> str:
170
+ return str(value.quantize(_CENT))
171
+
172
+
173
+ def _signed(value: Decimal) -> str:
174
+ text = _money(value)
175
+ return text if value < 0 else f"+{text}"
176
+
177
+
178
+ class Report:
179
+ """One run's full report: the untouched frozen ``BacktestResult``, derived
180
+
181
+ ``SummaryStats``, the broker's half-turn trade list, and the
182
+ ``CombineParams`` the run used. ``str(report)`` renders the combine
183
+ verdict, balance path, day-by-day trail, and summary stats as plain
184
+ aligned text — deterministically (pure function of the held frozen data;
185
+ no wall clock, no unordered iteration).
186
+ """
187
+
188
+ __slots__ = ("bars_gated", "params", "result", "stats", "trades")
189
+
190
+ result: BacktestResult
191
+ stats: SummaryStats
192
+ trades: tuple[HalfTradeModel, ...]
193
+ params: CombineParams
194
+ bars_gated: int | None
195
+ """Warmup-gated bars before the strategy's first decision (``None`` when
196
+ the strategy is not a ``SymbolStrategy``)."""
197
+
198
+ def __init__(
199
+ self,
200
+ *,
201
+ result: BacktestResult,
202
+ stats: SummaryStats,
203
+ trades: tuple[HalfTradeModel, ...],
204
+ params: CombineParams,
205
+ bars_gated: int | None = None,
206
+ ) -> None:
207
+ self.result = result
208
+ self.stats = stats
209
+ self.trades = trades
210
+ self.params = params
211
+ self.bars_gated = bars_gated
212
+
213
+ def __str__(self) -> str:
214
+ result = self.result
215
+ stats = self.stats
216
+ pct = (self.params.consistency_pct * 100).quantize(Decimal("1"))
217
+ cap = self.params.consistency_pct * result.total_profit
218
+ lines = [
219
+ f"== Topstep Combine {self.params.size.value}: {result.verdict.name} ==",
220
+ f" {result.reason}",
221
+ "",
222
+ f"balance {_money(result.starting_balance)} -> "
223
+ f"{_money(result.ending_balance)} (net {_signed(stats.net_pnl)})",
224
+ f"total profit {_money(result.total_profit)} "
225
+ f"(target {_money(result.profit_target)})",
226
+ f"best day {_money(result.best_day)} (cap {_money(cap)} = {pct}% of total)",
227
+ f"MLL floor {_money(result.floor)} (distance {_money(stats.distance_to_floor)})",
228
+ f"days traded {result.days_traded} closing trades {stats.closed_trades} "
229
+ f"half-turns {result.trade_count}",
230
+ ]
231
+ if result.breach is not None:
232
+ lines.append(
233
+ f"breach {result.breach.kind.name} at equity "
234
+ f"{_money(result.breach.equity)} (limit {_money(result.breach.limit)})"
235
+ )
236
+ if self.bars_gated is not None:
237
+ lines.append(f"warmup {self.bars_gated} bars gated before the first decision")
238
+ if result.rejections:
239
+ total = sum(count for _, count in result.rejections)
240
+ detail = ", ".join(f"{count}x code {code}" for code, count in result.rejections)
241
+ lines.append(
242
+ f"REJECTED {total} order placement(s) refused by the broker "
243
+ f"({detail}) — intent diverged from execution"
244
+ )
245
+ lines.extend(("", "day trail"))
246
+ if result.day_records:
247
+ for record in result.day_records:
248
+ flag = " *" if record.had_trade else ""
249
+ lines.append(
250
+ f" {record.day} eod={_money(record.eod_balance):>12} "
251
+ f"pnl={_signed(record.day_pnl):>10} "
252
+ f"floor={_money(record.floor_after):>10}{flag}"
253
+ )
254
+ else:
255
+ lines.append(" (no closed trading days)")
256
+ win_rate = "n/a" if stats.win_rate is None else f"{(stats.win_rate * 100).quantize(_CENT)}%"
257
+ expectancy = "n/a" if stats.expectancy is None else _signed(stats.expectancy)
258
+ factor = "n/a" if stats.profit_factor is None else str(stats.profit_factor.quantize(_CENT))
259
+ lines.extend(
260
+ (
261
+ "",
262
+ "summary stats",
263
+ f" closed trades {stats.closed_trades}",
264
+ f" win rate (gross) {win_rate}",
265
+ f" expectancy (gross) {expectancy}",
266
+ f" profit factor (gross) {factor}",
267
+ f" max drawdown (close) {_money(stats.max_drawdown)}",
268
+ f" net P&L {_signed(stats.net_pnl)}",
269
+ f" distance to floor {_money(stats.distance_to_floor)}",
270
+ f" consistency headroom {_signed(stats.consistency_headroom)}",
271
+ f" days traded {stats.days_traded}",
272
+ )
273
+ )
274
+ lines.extend(("", _PROVENANCE))
275
+ return "\n".join(lines)
276
+
277
+ __repr__ = __str__
278
+
279
+
280
+ class Backtest:
281
+ """The two-line runner: assemble the sim stack correctly and run it once.
282
+
283
+ ``data`` is a time-ordered ``Bar`` sequence (feed ordering is enforced at
284
+ construction); ``strategy`` is a bound-ready instance with typed
285
+ constructor parameters — never a class (docs/STRATEGY_API.md §4). Knobs
286
+ are only things that exist in this project: the account size (and its
287
+ optional Personal DLL), validation strictness, the sim account id, and
288
+ the fill/broker fidelity configs. Economics are never knobs.
289
+ """
290
+
291
+ def __init__(
292
+ self,
293
+ data: Sequence[Bar],
294
+ strategy: Strategy,
295
+ *,
296
+ account: AccountSize = AccountSize.S50K,
297
+ dll_enabled: bool = False,
298
+ validate: bool = True,
299
+ account_id: int = 1,
300
+ fill_config: BarFillConfig | None = None,
301
+ broker_config: SimBrokerConfig | None = None,
302
+ fee_model: TopstepFees | None = None,
303
+ **rejected: object,
304
+ ) -> None:
305
+ _refuse_strategy_class(strategy)
306
+ _refuse_unknown_kwargs(rejected)
307
+ self._bars: tuple[Bar, ...] = tuple(data)
308
+ self._strategy = strategy
309
+ self._account = account
310
+ self._dll_enabled = dll_enabled
311
+ self._account_id = account_id
312
+ self._fill_config = fill_config
313
+ self._broker_config = broker_config
314
+ self._fee_model = TopstepFees() if fee_model is None else fee_model
315
+ self._ran = False
316
+
317
+ # Instruments derive from the feed's contract ids — never hand-passed;
318
+ # each contract validates against ITS spec (validate_bars is single-spec).
319
+ groups: dict[str, list[Bar]] = {}
320
+ for bar in self._bars:
321
+ groups.setdefault(bar.bar_type.contract_id, []).append(bar)
322
+ self._instruments = {cid: _spec_for_contract_id(cid) for cid in groups}
323
+ if validate:
324
+ errors: list[ValidationIssue] = []
325
+ for cid, group in groups.items():
326
+ report = validate_bars(group, self._instruments[cid])
327
+ # The refuse/proceed decision is the validator's (report.ok:
328
+ # INFO-only findings proceed); the filter below only strips
329
+ # INFO findings from the error listing.
330
+ if not report.ok:
331
+ errors.extend(i for i in report.issues if i.code not in INFO_CODES)
332
+ if errors:
333
+ raise DataValidationError(tuple(errors))
334
+ # Construction enforces the ordering invariant even under validate=False:
335
+ # the engine trusts the DataFeed contract unconditionally.
336
+ self._feed = ListBarFeed(self._bars)
337
+
338
+ @classmethod
339
+ def from_dataframe(
340
+ cls,
341
+ df: Any,
342
+ strategy: Strategy,
343
+ *,
344
+ contract_id: str,
345
+ stamp: Literal["open", "close"],
346
+ unit: AggregateBarUnit,
347
+ unit_number: int,
348
+ account: AccountSize = AccountSize.S50K,
349
+ dll_enabled: bool = False,
350
+ validate: bool = True,
351
+ account_id: int = 1,
352
+ fill_config: BarFillConfig | None = None,
353
+ broker_config: SimBrokerConfig | None = None,
354
+ fee_model: TopstepFees | None = None,
355
+ **rejected: object,
356
+ ) -> Backtest:
357
+ """Wrangle a pandas OHLCV DataFrame, then assemble as usual.
358
+
359
+ ``stamp``, ``unit``, and ``unit_number`` stay REQUIRED (no defaults),
360
+ exactly as on the wrangler: the caller must declare whether source
361
+ timestamps are bar opens or closes AND the bar span — guessing the
362
+ stamp is the classic silent one-bar look-ahead, and defaulting the
363
+ span would mis-stamp every non-1-minute bar's close (a 5-minute bar
364
+ stamped with a 60s span acts 4 minutes early against the session
365
+ clock).
366
+ """
367
+ # Refusals come BEFORE the wrangle: a wrangler error (naive
368
+ # timestamps, off-grid row) must never mask a §4 rejection.
369
+ _refuse_strategy_class(strategy)
370
+ _refuse_unknown_kwargs(rejected)
371
+ bars = bars_from_dataframe(
372
+ df,
373
+ contract_id=contract_id,
374
+ spec=_spec_for_contract_id(contract_id),
375
+ unit=unit,
376
+ unit_number=unit_number,
377
+ stamp=stamp,
378
+ )
379
+ return cls(
380
+ bars,
381
+ strategy,
382
+ account=account,
383
+ dll_enabled=dll_enabled,
384
+ validate=validate,
385
+ account_id=account_id,
386
+ fill_config=fill_config,
387
+ broker_config=broker_config,
388
+ fee_model=fee_model,
389
+ **rejected,
390
+ )
391
+
392
+ def run(self) -> Report:
393
+ """Run to completion synchronously (wraps ``asyncio.run``).
394
+
395
+ Raises:
396
+ RuntimeError: If called from inside a running event loop — use
397
+ ``await backtest.arun()`` there instead.
398
+ """
399
+ try:
400
+ asyncio.get_running_loop()
401
+ except RuntimeError:
402
+ return asyncio.run(self.arun())
403
+ raise RuntimeError(
404
+ "Backtest.run() was called from a running event loop; "
405
+ "use `await backtest.arun()` instead"
406
+ )
407
+
408
+ async def arun(self) -> Report:
409
+ """Assemble fresh engine state, run once, and report.
410
+
411
+ Clock, kernel, broker, engine, and the strategy instance are all
412
+ stateful and single-use, so a ``Backtest`` refuses to run twice.
413
+ """
414
+ if self._ran:
415
+ raise RuntimeError(
416
+ "this Backtest has already run — engine, broker, and strategy "
417
+ "state are single-use; construct a new Backtest (with a fresh "
418
+ "strategy instance) for another run"
419
+ )
420
+ self._ran = True
421
+ params = combine_params(self._account, dll_enabled=self._dll_enabled)
422
+ # ONE clock for broker AND engine — the assembly invariant this facade
423
+ # exists to enforce (a broker on its own clock stamps accepted_ts=0,
424
+ # making every order eligible for the current bar: silent look-ahead).
425
+ clock = TestClock()
426
+ broker = SimBroker(
427
+ account_id=self._account_id,
428
+ instruments=self._instruments,
429
+ fill_model=BarFillModel(self._fill_config),
430
+ fee_model=self._fee_model,
431
+ kernel=CombineKernel(params),
432
+ clock=clock,
433
+ config=self._broker_config,
434
+ )
435
+ engine = BacktestEngine(
436
+ feed=self._feed, broker=broker, strategy=self._strategy, clock=clock
437
+ )
438
+ result = await engine.run()
439
+ trades = broker.trades
440
+ stats = compute_summary(result, trades=trades, consistency_pct=params.consistency_pct)
441
+ bars_gated = (
442
+ self._strategy.bars_gated if isinstance(self._strategy, SymbolStrategy) else None
443
+ )
444
+ return Report(
445
+ result=result, stats=stats, trades=trades, params=params, bars_gated=bars_gated
446
+ )
@@ -0,0 +1,46 @@
1
+ """topstep_backtest.indicators — every indicator is TA-Lib.
2
+
3
+ ``TalibIndicator`` drives any of TA-Lib's ~160 functions bar by bar; the named
4
+ classes are typed spellings of the common ones. See ``talib_adapter`` for the
5
+ causality, streaming-equals-batch, and bounded-history parity guarantees.
6
+ """
7
+
8
+ from .base import Indicator, NotReadyError, ValueSource
9
+ from .library import (
10
+ Adx,
11
+ Atr,
12
+ BBands,
13
+ Cross,
14
+ Ema,
15
+ Highest,
16
+ Lowest,
17
+ Macd,
18
+ Obv,
19
+ Rsi,
20
+ Sma,
21
+ StdDev,
22
+ Stoch,
23
+ )
24
+ from .talib_adapter import TalibIndicator, TalibLine, talib_function_names
25
+
26
+ __all__ = [
27
+ "Adx",
28
+ "Atr",
29
+ "BBands",
30
+ "Cross",
31
+ "Ema",
32
+ "Highest",
33
+ "Indicator",
34
+ "Lowest",
35
+ "Macd",
36
+ "NotReadyError",
37
+ "Obv",
38
+ "Rsi",
39
+ "Sma",
40
+ "StdDev",
41
+ "Stoch",
42
+ "TalibIndicator",
43
+ "TalibLine",
44
+ "ValueSource",
45
+ "talib_function_names",
46
+ ]
@@ -0,0 +1,57 @@
1
+ """The indicator surface every consumer depends on.
2
+
3
+ Kept in its own module so ``protocols``-style contracts stay separable from the
4
+ TA-Lib machinery that implements them: ``SymbolStrategy.use()`` needs only
5
+ ``Indicator``, and ``Cross`` needs only ``_ValueSource`` — neither imports
6
+ TA-Lib.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ if TYPE_CHECKING:
14
+ from decimal import Decimal
15
+
16
+ from ..protocols import Bar
17
+
18
+ __all__ = ["Indicator", "NotReadyError"]
19
+
20
+
21
+ class NotReadyError(Exception):
22
+ """``value`` was read before the indicator had seen ``lookback`` bars."""
23
+
24
+
25
+ @runtime_checkable
26
+ class Indicator(Protocol):
27
+ """The surface ``SymbolStrategy.use()`` requires of a registered indicator."""
28
+
29
+ @property
30
+ def lookback(self) -> int:
31
+ """Bars needed before the indicator is ready."""
32
+ ...
33
+
34
+ @property
35
+ def ready(self) -> bool: ...
36
+
37
+ def update(self, bar: Bar) -> None: ...
38
+
39
+
40
+ @runtime_checkable
41
+ class ValueSource(Protocol):
42
+ """Minimal structural input for ``Cross``: anything exposing a Decimal series.
43
+
44
+ Runtime-checkable so ``use()`` can refuse a ``Cross`` over something with no
45
+ ``value`` (notably another ``Cross``) at registration, rather than letting it
46
+ die with an ``AttributeError`` hours into a run — and only once both inner
47
+ inputs happen to be ready.
48
+ """
49
+
50
+ @property
51
+ def lookback(self) -> int: ...
52
+
53
+ @property
54
+ def ready(self) -> bool: ...
55
+
56
+ @property
57
+ def value(self) -> Decimal: ...