quantbt-engine 1.0.7__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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Phase 7 benchmark runner.
|
|
4
|
+
|
|
5
|
+
This is a lightweight stdlib CLI around the public V2 engines. It intentionally
|
|
6
|
+
does not require pytest or a benchmark plugin so it can run inside notebooks,
|
|
7
|
+
SSH shells, and CI jobs with the same command.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import gc
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import resource
|
|
17
|
+
import statistics
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import tracemalloc
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PACKAGE_DIR = Path(__file__).resolve().parents[1]
|
|
27
|
+
PROJECT_DIR = PACKAGE_DIR.parent
|
|
28
|
+
if str(PROJECT_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(PROJECT_DIR))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BenchmarkProfile:
|
|
34
|
+
name: str
|
|
35
|
+
bars: int
|
|
36
|
+
symbols: int
|
|
37
|
+
order_count: int
|
|
38
|
+
repeats: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class BenchmarkRecord:
|
|
43
|
+
backend: str
|
|
44
|
+
profile: str
|
|
45
|
+
status: str
|
|
46
|
+
bars: int
|
|
47
|
+
symbols: int
|
|
48
|
+
bar_symbols: int
|
|
49
|
+
order_count: int
|
|
50
|
+
event_count: int
|
|
51
|
+
signal_transitions: int
|
|
52
|
+
warmup_seconds: Optional[float]
|
|
53
|
+
runtime_seconds: Optional[float]
|
|
54
|
+
runtime_min_seconds: Optional[float]
|
|
55
|
+
runtime_max_seconds: Optional[float]
|
|
56
|
+
peak_memory_mb: Optional[float]
|
|
57
|
+
rss_delta_mb: Optional[float]
|
|
58
|
+
throughput_bar_symbols_per_second: Optional[float] = None
|
|
59
|
+
throughput_orders_per_second: Optional[float] = None
|
|
60
|
+
threshold_metric: Optional[str] = None
|
|
61
|
+
threshold_value: Optional[float] = None
|
|
62
|
+
threshold_limit: Optional[float] = None
|
|
63
|
+
threshold_passed: Optional[bool] = None
|
|
64
|
+
error: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
PROFILES = {
|
|
68
|
+
"smoke": BenchmarkProfile(name="smoke", bars=1_000, symbols=4, order_count=500, repeats=2),
|
|
69
|
+
"standard": BenchmarkProfile(name="standard", bars=25_000, symbols=20, order_count=25_000, repeats=5),
|
|
70
|
+
"large": BenchmarkProfile(name="large", bars=100_000, symbols=50, order_count=100_000, repeats=3),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
75
|
+
parser = argparse.ArgumentParser(description="Run quantbt Phase 7 benchmarks.")
|
|
76
|
+
parser.add_argument("--profile", choices=sorted(PROFILES), default="smoke")
|
|
77
|
+
parser.add_argument("--repeats", type=int, default=None)
|
|
78
|
+
parser.add_argument("--include-nautilus", action="store_true")
|
|
79
|
+
parser.add_argument("--no-tracemalloc", action="store_true", help="Measure runtime without Python allocation tracing.")
|
|
80
|
+
parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_results.json")
|
|
81
|
+
parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "out" / "phase7_results.md")
|
|
82
|
+
args = parser.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
profile = PROFILES[args.profile]
|
|
85
|
+
if args.repeats is not None:
|
|
86
|
+
profile = BenchmarkProfile(
|
|
87
|
+
name=profile.name,
|
|
88
|
+
bars=profile.bars,
|
|
89
|
+
symbols=profile.symbols,
|
|
90
|
+
order_count=profile.order_count,
|
|
91
|
+
repeats=max(1, args.repeats),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
records = run_all(profile=profile, include_nautilus=args.include_nautilus, trace_memory=not args.no_tracemalloc)
|
|
95
|
+
write_outputs(records=records, profile=profile, json_out=args.json_out, md_out=args.md_out)
|
|
96
|
+
for record in records:
|
|
97
|
+
print(_record_line(record))
|
|
98
|
+
return 0 if all(r.status in {"passed", "skipped"} for r in records) else 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_all(profile: BenchmarkProfile, include_nautilus: bool = False, trace_memory: bool = True) -> List[BenchmarkRecord]:
|
|
102
|
+
records = [
|
|
103
|
+
run_native_vectorized(profile, trace_memory=trace_memory),
|
|
104
|
+
run_native_event(profile, trace_memory=trace_memory),
|
|
105
|
+
run_native_event_prepared(profile, trace_memory=trace_memory),
|
|
106
|
+
run_portfolio_legacy(profile, trace_memory=trace_memory),
|
|
107
|
+
run_native_portfolio(profile, trace_memory=trace_memory),
|
|
108
|
+
]
|
|
109
|
+
if include_nautilus:
|
|
110
|
+
records.append(run_nautilus(profile, trace_memory=trace_memory))
|
|
111
|
+
else:
|
|
112
|
+
records.append(_skipped("nautilus", profile, "pass --include-nautilus to run optional backend"))
|
|
113
|
+
return records
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run_native_vectorized(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
117
|
+
try:
|
|
118
|
+
import pandas as pd
|
|
119
|
+
|
|
120
|
+
from quantbt import AccountConfig, BacktestEngineV2
|
|
121
|
+
|
|
122
|
+
idx, frames = _make_market_frames(profile.bars, profile.symbols)
|
|
123
|
+
signals = _make_signals(idx, profile.symbols)
|
|
124
|
+
transitions = _count_signal_transitions(signals.values())
|
|
125
|
+
|
|
126
|
+
def workload():
|
|
127
|
+
engine = BacktestEngineV2(
|
|
128
|
+
data=frames,
|
|
129
|
+
signals=signals,
|
|
130
|
+
backend="native_vectorized",
|
|
131
|
+
account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0),
|
|
132
|
+
alloc_per_trade=10_000.0,
|
|
133
|
+
hedge_type="signal_notional",
|
|
134
|
+
use_funding=False,
|
|
135
|
+
)
|
|
136
|
+
return engine.result.equity.iloc[-1]
|
|
137
|
+
|
|
138
|
+
return _measure(
|
|
139
|
+
backend="native_vectorized",
|
|
140
|
+
profile=profile,
|
|
141
|
+
workload=workload,
|
|
142
|
+
order_count=transitions,
|
|
143
|
+
event_count=profile.bars * profile.symbols,
|
|
144
|
+
signal_transitions=transitions,
|
|
145
|
+
trace_memory=trace_memory,
|
|
146
|
+
)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
return _failed("native_vectorized", profile, exc)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_native_event(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
152
|
+
try:
|
|
153
|
+
from quantbt import AccountConfig, BacktestEngineV2
|
|
154
|
+
|
|
155
|
+
idx, frames = _make_market_frames(profile.bars, profile.symbols)
|
|
156
|
+
orders = _make_orders(idx, profile.order_count, profile.symbols)
|
|
157
|
+
|
|
158
|
+
def workload():
|
|
159
|
+
engine = BacktestEngineV2(
|
|
160
|
+
data=frames,
|
|
161
|
+
backend="native_event",
|
|
162
|
+
orders=orders,
|
|
163
|
+
account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0),
|
|
164
|
+
use_funding=False,
|
|
165
|
+
)
|
|
166
|
+
return engine.result.equity.iloc[-1]
|
|
167
|
+
|
|
168
|
+
return _measure(
|
|
169
|
+
backend="native_event",
|
|
170
|
+
profile=profile,
|
|
171
|
+
workload=workload,
|
|
172
|
+
order_count=len(orders),
|
|
173
|
+
event_count=profile.bars + len(orders),
|
|
174
|
+
signal_transitions=0,
|
|
175
|
+
trace_memory=trace_memory,
|
|
176
|
+
)
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
return _failed("native_event", profile, exc)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_native_event_prepared(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
182
|
+
try:
|
|
183
|
+
from quantbt import AccountConfig
|
|
184
|
+
from quantbt.backends import NativeEventBackend, NativeEventConfig
|
|
185
|
+
|
|
186
|
+
idx, frames = _make_market_frames(profile.bars, profile.symbols)
|
|
187
|
+
orders = _make_orders(idx, profile.order_count, profile.symbols)
|
|
188
|
+
symbols = list(frames.keys())
|
|
189
|
+
closes = {symbol: frame["close"] for symbol, frame in frames.items()}
|
|
190
|
+
highs = {symbol: frame["high"] for symbol, frame in frames.items()}
|
|
191
|
+
lows = {symbol: frame["low"] for symbol, frame in frames.items()}
|
|
192
|
+
backend = NativeEventBackend(
|
|
193
|
+
NativeEventConfig(
|
|
194
|
+
account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0),
|
|
195
|
+
use_funding=False,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
market_arrays = backend.prepare_market_arrays(
|
|
199
|
+
datetime_index=idx,
|
|
200
|
+
closes=closes,
|
|
201
|
+
highs=highs,
|
|
202
|
+
lows=lows,
|
|
203
|
+
symbols=symbols,
|
|
204
|
+
)
|
|
205
|
+
compiled_orders = backend.compile_orders(datetime_index=idx, orders=orders, symbols=symbols)
|
|
206
|
+
|
|
207
|
+
def workload():
|
|
208
|
+
result = backend.run_orders(
|
|
209
|
+
datetime_index=idx,
|
|
210
|
+
orders=orders,
|
|
211
|
+
closes=closes,
|
|
212
|
+
highs=highs,
|
|
213
|
+
lows=lows,
|
|
214
|
+
symbols=symbols,
|
|
215
|
+
market_arrays=market_arrays,
|
|
216
|
+
compiled_orders=compiled_orders,
|
|
217
|
+
)
|
|
218
|
+
return result.equity.iloc[-1]
|
|
219
|
+
|
|
220
|
+
return _measure(
|
|
221
|
+
backend="native_event_prepared",
|
|
222
|
+
profile=profile,
|
|
223
|
+
workload=workload,
|
|
224
|
+
order_count=len(orders),
|
|
225
|
+
event_count=profile.bars + len(orders),
|
|
226
|
+
signal_transitions=0,
|
|
227
|
+
trace_memory=trace_memory,
|
|
228
|
+
)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
return _failed("native_event_prepared", profile, exc)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_portfolio_legacy(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
234
|
+
try:
|
|
235
|
+
from quantbt import AccountConfig, PortfolioBacktestEngine
|
|
236
|
+
|
|
237
|
+
idx, frames = _make_market_frames(profile.bars, profile.symbols)
|
|
238
|
+
positions = _make_portfolio_positions(idx, profile.symbols)
|
|
239
|
+
closes = {symbol: frame["close"] for symbol, frame in frames.items()}
|
|
240
|
+
transitions = _count_signal_transitions(positions.values())
|
|
241
|
+
|
|
242
|
+
def workload():
|
|
243
|
+
engine = PortfolioBacktestEngine(
|
|
244
|
+
positions=positions,
|
|
245
|
+
closes=closes,
|
|
246
|
+
highs=closes,
|
|
247
|
+
lows=closes,
|
|
248
|
+
datetime_index=idx,
|
|
249
|
+
mode="longshort",
|
|
250
|
+
account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0),
|
|
251
|
+
fee_rate=0.0,
|
|
252
|
+
alloc_per_trade=10_000.0,
|
|
253
|
+
use_funding=False,
|
|
254
|
+
)
|
|
255
|
+
return engine.result.equity.iloc[-1]
|
|
256
|
+
|
|
257
|
+
return _measure(
|
|
258
|
+
backend="portfolio_legacy",
|
|
259
|
+
profile=profile,
|
|
260
|
+
workload=workload,
|
|
261
|
+
order_count=transitions,
|
|
262
|
+
event_count=profile.bars * profile.symbols,
|
|
263
|
+
signal_transitions=transitions,
|
|
264
|
+
trace_memory=trace_memory,
|
|
265
|
+
)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
return _failed("portfolio_legacy", profile, exc)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_native_portfolio(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
271
|
+
try:
|
|
272
|
+
from quantbt import AccountConfig, PortfolioBacktestEngine
|
|
273
|
+
|
|
274
|
+
idx, frames = _make_market_frames(profile.bars, profile.symbols)
|
|
275
|
+
positions = _make_portfolio_positions(idx, profile.symbols)
|
|
276
|
+
closes = {symbol: frame["close"] for symbol, frame in frames.items()}
|
|
277
|
+
transitions = _count_signal_transitions(positions.values())
|
|
278
|
+
|
|
279
|
+
def workload():
|
|
280
|
+
engine = PortfolioBacktestEngine(
|
|
281
|
+
positions=positions,
|
|
282
|
+
closes=closes,
|
|
283
|
+
highs=closes,
|
|
284
|
+
lows=closes,
|
|
285
|
+
datetime_index=idx,
|
|
286
|
+
mode="longshort",
|
|
287
|
+
backend="native_portfolio",
|
|
288
|
+
account=AccountConfig(initial_capital=1_000_000.0, leverage=10.0),
|
|
289
|
+
fee_rate=0.0,
|
|
290
|
+
alloc_per_trade=10_000.0,
|
|
291
|
+
hedge_type="signal_notional",
|
|
292
|
+
use_funding=False,
|
|
293
|
+
)
|
|
294
|
+
return engine.result.equity.iloc[-1]
|
|
295
|
+
|
|
296
|
+
return _measure(
|
|
297
|
+
backend="native_portfolio",
|
|
298
|
+
profile=profile,
|
|
299
|
+
workload=workload,
|
|
300
|
+
order_count=transitions,
|
|
301
|
+
event_count=profile.bars * profile.symbols,
|
|
302
|
+
signal_transitions=transitions,
|
|
303
|
+
trace_memory=trace_memory,
|
|
304
|
+
)
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
return _failed("native_portfolio", profile, exc)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_nautilus(profile: BenchmarkProfile, trace_memory: bool = True) -> BenchmarkRecord:
|
|
310
|
+
try:
|
|
311
|
+
from quantbt import AccountConfig, BacktestEngineV2
|
|
312
|
+
from quantbt.adapters.nautilus import NautilusBacktestEngine
|
|
313
|
+
|
|
314
|
+
NautilusBacktestEngine.check_available()
|
|
315
|
+
idx, frames = _make_market_frames(min(profile.bars, 10_000), 1)
|
|
316
|
+
symbol, frame = next(iter(frames.items()))
|
|
317
|
+
signal = _make_signals(idx, 1)[symbol]
|
|
318
|
+
transitions = _count_signal_transitions([signal])
|
|
319
|
+
|
|
320
|
+
def workload():
|
|
321
|
+
engine = BacktestEngineV2(
|
|
322
|
+
data=frame,
|
|
323
|
+
signals=signal,
|
|
324
|
+
symbols=["BTCUSDT-PERP.BINANCE"],
|
|
325
|
+
backend="nautilus",
|
|
326
|
+
account=AccountConfig(initial_capital=10_000.0, leverage=10.0),
|
|
327
|
+
alloc_per_trade=1_000.0,
|
|
328
|
+
use_funding=False,
|
|
329
|
+
)
|
|
330
|
+
return engine.result.equity.iloc[-1]
|
|
331
|
+
|
|
332
|
+
nautilus_profile = BenchmarkProfile(
|
|
333
|
+
name=profile.name,
|
|
334
|
+
bars=len(idx),
|
|
335
|
+
symbols=1,
|
|
336
|
+
order_count=transitions,
|
|
337
|
+
repeats=max(1, min(profile.repeats, 2)),
|
|
338
|
+
)
|
|
339
|
+
return _measure(
|
|
340
|
+
backend="nautilus",
|
|
341
|
+
profile=nautilus_profile,
|
|
342
|
+
workload=workload,
|
|
343
|
+
order_count=transitions,
|
|
344
|
+
event_count=len(idx),
|
|
345
|
+
signal_transitions=transitions,
|
|
346
|
+
trace_memory=trace_memory,
|
|
347
|
+
)
|
|
348
|
+
except ImportError as exc:
|
|
349
|
+
return _skipped("nautilus", profile, str(exc))
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
return _failed("nautilus", profile, exc)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _measure(
|
|
355
|
+
backend: str,
|
|
356
|
+
profile: BenchmarkProfile,
|
|
357
|
+
workload,
|
|
358
|
+
order_count: int,
|
|
359
|
+
event_count: int,
|
|
360
|
+
signal_transitions: int,
|
|
361
|
+
trace_memory: bool = True,
|
|
362
|
+
) -> BenchmarkRecord:
|
|
363
|
+
gc.collect()
|
|
364
|
+
rss_before = _rss_mb()
|
|
365
|
+
if trace_memory:
|
|
366
|
+
tracemalloc.start()
|
|
367
|
+
warmup_start = time.perf_counter()
|
|
368
|
+
workload()
|
|
369
|
+
warmup_seconds = time.perf_counter() - warmup_start
|
|
370
|
+
|
|
371
|
+
runtimes: List[float] = []
|
|
372
|
+
for _ in range(profile.repeats):
|
|
373
|
+
start = time.perf_counter()
|
|
374
|
+
workload()
|
|
375
|
+
runtimes.append(time.perf_counter() - start)
|
|
376
|
+
peak = 0
|
|
377
|
+
if trace_memory:
|
|
378
|
+
current, peak = tracemalloc.get_traced_memory()
|
|
379
|
+
del current
|
|
380
|
+
tracemalloc.stop()
|
|
381
|
+
rss_after = _rss_mb()
|
|
382
|
+
|
|
383
|
+
runtime = statistics.mean(runtimes)
|
|
384
|
+
record = BenchmarkRecord(
|
|
385
|
+
backend=backend,
|
|
386
|
+
profile=profile.name,
|
|
387
|
+
status="passed",
|
|
388
|
+
bars=profile.bars,
|
|
389
|
+
symbols=profile.symbols,
|
|
390
|
+
bar_symbols=profile.bars * profile.symbols,
|
|
391
|
+
order_count=order_count,
|
|
392
|
+
event_count=event_count,
|
|
393
|
+
signal_transitions=signal_transitions,
|
|
394
|
+
warmup_seconds=warmup_seconds,
|
|
395
|
+
runtime_seconds=runtime,
|
|
396
|
+
runtime_min_seconds=min(runtimes),
|
|
397
|
+
runtime_max_seconds=max(runtimes),
|
|
398
|
+
peak_memory_mb=(peak / (1024 * 1024)) if trace_memory else None,
|
|
399
|
+
rss_delta_mb=max(0.0, rss_after - rss_before),
|
|
400
|
+
throughput_bar_symbols_per_second=(profile.bars * profile.symbols / runtime) if runtime > 0.0 else None,
|
|
401
|
+
throughput_orders_per_second=(order_count / runtime) if runtime > 0.0 and order_count > 0 else None,
|
|
402
|
+
)
|
|
403
|
+
return _attach_threshold(record)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _make_market_frames(bars: int, symbols: int):
|
|
407
|
+
import numpy as np
|
|
408
|
+
import pandas as pd
|
|
409
|
+
|
|
410
|
+
idx = pd.date_range("2020-01-01", periods=bars, freq="1min", tz="UTC")
|
|
411
|
+
base = 100.0 + np.cumsum(np.sin(np.arange(bars) / 37.0) * 0.05)
|
|
412
|
+
frames = {}
|
|
413
|
+
for j in range(symbols):
|
|
414
|
+
close = base + j * 0.25
|
|
415
|
+
frames[f"SYM{j:03d}"] = pd.DataFrame(
|
|
416
|
+
{
|
|
417
|
+
"open": close,
|
|
418
|
+
"high": close * 1.001,
|
|
419
|
+
"low": close * 0.999,
|
|
420
|
+
"close": close,
|
|
421
|
+
"volume": 1_000.0 + j,
|
|
422
|
+
},
|
|
423
|
+
index=idx,
|
|
424
|
+
)
|
|
425
|
+
return idx, frames
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _make_signals(idx, symbols: int):
|
|
429
|
+
import numpy as np
|
|
430
|
+
import pandas as pd
|
|
431
|
+
|
|
432
|
+
out = {}
|
|
433
|
+
n = len(idx)
|
|
434
|
+
grid = np.arange(n)
|
|
435
|
+
for j in range(symbols):
|
|
436
|
+
raw = np.where(((grid // (25 + j % 5)) + j) % 4 == 0, 1.0, 0.0)
|
|
437
|
+
out[f"SYM{j:03d}"] = pd.Series(raw, index=idx)
|
|
438
|
+
return out
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _make_portfolio_positions(idx, symbols: int):
|
|
442
|
+
import numpy as np
|
|
443
|
+
import pandas as pd
|
|
444
|
+
|
|
445
|
+
out = {}
|
|
446
|
+
n = len(idx)
|
|
447
|
+
grid = np.arange(n)
|
|
448
|
+
for j in range(symbols):
|
|
449
|
+
active = np.where(((grid // (40 + j % 7)) + j) % 5 == 0, 1.0, 0.0)
|
|
450
|
+
sign = 1.0 if j % 2 == 0 else -1.0
|
|
451
|
+
out[f"SYM{j:03d}"] = pd.Series(active * sign, index=idx)
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _make_orders(idx, order_count: int, symbols: int):
|
|
456
|
+
import numpy as np
|
|
457
|
+
|
|
458
|
+
from quantbt import OrderIntent, OrderSide, OrderType, TimeInForce
|
|
459
|
+
|
|
460
|
+
if order_count <= 0:
|
|
461
|
+
return []
|
|
462
|
+
positions = np.linspace(1, len(idx) - 1, num=order_count, dtype=int)
|
|
463
|
+
orders = []
|
|
464
|
+
for k, bar in enumerate(positions):
|
|
465
|
+
side = OrderSide.BUY if k % 2 == 0 else OrderSide.SELL
|
|
466
|
+
orders.append(
|
|
467
|
+
OrderIntent(
|
|
468
|
+
timestamp=idx[int(bar)],
|
|
469
|
+
symbol=f"SYM{k % symbols:03d}",
|
|
470
|
+
side=side,
|
|
471
|
+
order_type=OrderType.MARKET,
|
|
472
|
+
qty=1.0,
|
|
473
|
+
tif=TimeInForce.IOC,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
return orders
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _count_signal_transitions(signals: Iterable) -> int:
|
|
480
|
+
count = 0
|
|
481
|
+
for sig in signals:
|
|
482
|
+
values = sig.to_numpy()
|
|
483
|
+
if len(values) > 1:
|
|
484
|
+
count += int((values[1:] != values[:-1]).sum())
|
|
485
|
+
return count
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def write_outputs(
|
|
489
|
+
records: List[BenchmarkRecord],
|
|
490
|
+
profile: BenchmarkProfile,
|
|
491
|
+
json_out: Path,
|
|
492
|
+
md_out: Path,
|
|
493
|
+
) -> None:
|
|
494
|
+
json_out.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
md_out.parent.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
payload = {
|
|
497
|
+
"profile": asdict(profile),
|
|
498
|
+
"records": [asdict(record) for record in records],
|
|
499
|
+
"thresholds": _load_thresholds(),
|
|
500
|
+
}
|
|
501
|
+
json_out.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
502
|
+
md_out.write_text(_markdown_report(records, profile), encoding="utf-8")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _markdown_report(records: List[BenchmarkRecord], profile: BenchmarkProfile) -> str:
|
|
506
|
+
lines = [
|
|
507
|
+
"# Phase 7 Benchmark Results",
|
|
508
|
+
"",
|
|
509
|
+
f"Profile: `{profile.name}`",
|
|
510
|
+
"",
|
|
511
|
+
"| backend | status | bars | symbols | orders | events | warmup s | runtime s | peak MB | throughput | threshold | note |",
|
|
512
|
+
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |",
|
|
513
|
+
]
|
|
514
|
+
for record in records:
|
|
515
|
+
threshold = "-"
|
|
516
|
+
if record.threshold_metric is not None:
|
|
517
|
+
verdict = "pass" if record.threshold_passed else "fail"
|
|
518
|
+
threshold = f"{record.threshold_metric}={_fmt(record.threshold_value)} <= {_fmt(record.threshold_limit)} ({verdict})"
|
|
519
|
+
lines.append(
|
|
520
|
+
"| {backend} | {status} | {bars} | {symbols} | {orders} | {events} | {warmup} | {runtime} | {peak} | {throughput} | {threshold} | {note} |".format(
|
|
521
|
+
backend=record.backend,
|
|
522
|
+
status=record.status,
|
|
523
|
+
bars=record.bars,
|
|
524
|
+
symbols=record.symbols,
|
|
525
|
+
orders=record.order_count,
|
|
526
|
+
events=record.event_count,
|
|
527
|
+
warmup=_fmt(record.warmup_seconds),
|
|
528
|
+
runtime=_fmt(record.runtime_seconds),
|
|
529
|
+
peak=_fmt(record.peak_memory_mb),
|
|
530
|
+
throughput=_fmt(record.throughput_bar_symbols_per_second),
|
|
531
|
+
threshold=threshold,
|
|
532
|
+
note=record.error or "",
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
lines.append("")
|
|
536
|
+
lines.append("Thresholds: see `benchmarks/phase7_thresholds.json`.")
|
|
537
|
+
return "\n".join(lines) + "\n"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _record_line(record: BenchmarkRecord) -> str:
|
|
541
|
+
return (
|
|
542
|
+
f"{record.backend}: {record.status} "
|
|
543
|
+
f"warmup={_fmt(record.warmup_seconds)}s runtime={_fmt(record.runtime_seconds)}s "
|
|
544
|
+
f"peak={_fmt(record.peak_memory_mb)}MB {record.error or ''}"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _fmt(value: Optional[float]) -> str:
|
|
549
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
550
|
+
return "-"
|
|
551
|
+
return f"{value:.6f}"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _rss_mb() -> float:
|
|
555
|
+
try:
|
|
556
|
+
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
557
|
+
except Exception:
|
|
558
|
+
return 0.0
|
|
559
|
+
if sys.platform == "darwin":
|
|
560
|
+
return value / (1024 * 1024)
|
|
561
|
+
return value / 1024
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _failed(backend: str, profile: BenchmarkProfile, exc: Exception) -> BenchmarkRecord:
|
|
565
|
+
return BenchmarkRecord(
|
|
566
|
+
backend=backend,
|
|
567
|
+
profile=profile.name,
|
|
568
|
+
status="failed",
|
|
569
|
+
bars=profile.bars,
|
|
570
|
+
symbols=profile.symbols,
|
|
571
|
+
bar_symbols=profile.bars * profile.symbols,
|
|
572
|
+
order_count=0,
|
|
573
|
+
event_count=0,
|
|
574
|
+
signal_transitions=0,
|
|
575
|
+
warmup_seconds=None,
|
|
576
|
+
runtime_seconds=None,
|
|
577
|
+
runtime_min_seconds=None,
|
|
578
|
+
runtime_max_seconds=None,
|
|
579
|
+
peak_memory_mb=None,
|
|
580
|
+
rss_delta_mb=None,
|
|
581
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _skipped(backend: str, profile: BenchmarkProfile, reason: str) -> BenchmarkRecord:
|
|
586
|
+
return BenchmarkRecord(
|
|
587
|
+
backend=backend,
|
|
588
|
+
profile=profile.name,
|
|
589
|
+
status="skipped",
|
|
590
|
+
bars=profile.bars,
|
|
591
|
+
symbols=profile.symbols,
|
|
592
|
+
bar_symbols=profile.bars * profile.symbols,
|
|
593
|
+
order_count=0,
|
|
594
|
+
event_count=0,
|
|
595
|
+
signal_transitions=0,
|
|
596
|
+
warmup_seconds=None,
|
|
597
|
+
runtime_seconds=None,
|
|
598
|
+
runtime_min_seconds=None,
|
|
599
|
+
runtime_max_seconds=None,
|
|
600
|
+
peak_memory_mb=None,
|
|
601
|
+
rss_delta_mb=None,
|
|
602
|
+
error=reason,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _load_thresholds() -> Dict:
|
|
607
|
+
path = PACKAGE_DIR / "benchmarks" / "phase7_thresholds.json"
|
|
608
|
+
try:
|
|
609
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
610
|
+
except Exception:
|
|
611
|
+
return {}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _attach_threshold(record: BenchmarkRecord) -> BenchmarkRecord:
|
|
615
|
+
thresholds = _load_thresholds()
|
|
616
|
+
backend_thresholds = thresholds.get(record.backend, {})
|
|
617
|
+
if record.runtime_seconds is None:
|
|
618
|
+
return record
|
|
619
|
+
|
|
620
|
+
metric = None
|
|
621
|
+
value = None
|
|
622
|
+
limit = None
|
|
623
|
+
if record.profile == "smoke" and "smoke_max_runtime_seconds" in backend_thresholds:
|
|
624
|
+
metric = "runtime_seconds"
|
|
625
|
+
value = record.runtime_seconds
|
|
626
|
+
limit = float(backend_thresholds["smoke_max_runtime_seconds"])
|
|
627
|
+
elif record.backend in {"native_vectorized", "portfolio_legacy", "native_portfolio"}:
|
|
628
|
+
key = f"{record.profile}_max_seconds_per_million_bar_symbols"
|
|
629
|
+
if key in backend_thresholds and record.bar_symbols > 0:
|
|
630
|
+
metric = "seconds_per_million_bar_symbols"
|
|
631
|
+
value = record.runtime_seconds / (record.bar_symbols / 1_000_000.0)
|
|
632
|
+
limit = float(backend_thresholds[key])
|
|
633
|
+
elif record.backend in {"native_event", "native_event_prepared"}:
|
|
634
|
+
key = f"{record.profile}_max_seconds_per_100k_orders"
|
|
635
|
+
if key in backend_thresholds and record.order_count > 0:
|
|
636
|
+
metric = "seconds_per_100k_orders"
|
|
637
|
+
value = record.runtime_seconds / (record.order_count / 100_000.0)
|
|
638
|
+
limit = float(backend_thresholds[key])
|
|
639
|
+
elif record.backend == "nautilus":
|
|
640
|
+
key = f"{record.profile}_max_seconds_per_100k_bars"
|
|
641
|
+
if key in backend_thresholds and record.bars > 0:
|
|
642
|
+
metric = "seconds_per_100k_bars"
|
|
643
|
+
value = record.runtime_seconds / (record.bars / 100_000.0)
|
|
644
|
+
limit = float(backend_thresholds[key])
|
|
645
|
+
|
|
646
|
+
record.threshold_metric = metric
|
|
647
|
+
record.threshold_value = value
|
|
648
|
+
record.threshold_limit = limit
|
|
649
|
+
record.threshold_passed = None if value is None or limit is None else bool(value <= limit)
|
|
650
|
+
return record
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
if __name__ == "__main__":
|
|
654
|
+
raise SystemExit(main())
|