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,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: ...
|