bench: add three-way speed benchmark (backtesting.py vs py-next vs native rust)

Adds a native-Rust SMA strategy path (Engine.run_native_sma) so we can time
the pure-Rust engine against the Python-callback path and backtesting.py.
This commit is contained in:
KhizarImran
2026-07-18 00:08:49 +01:00
parent c31e0088db
commit 494f157c13
2 changed files with 186 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
"""
Speed benchmark: backtesting.py vs backtestingfx, on the same SMA-cross strategy.
Three paths are timed:
1. backtesting.py — everything in Python
2. backtestingfx (py) — Rust engine + broker, but next() runs in Python each bar
3. backtestingfx (rust) — SMA strategy runs entirely in Rust, no per-bar Python call
The dataset is small (2000 bars), so it's tiled up to BARS to give the engines
real work. Timestamps are regenerated as a clean hourly range so backtesting.py
gets the sorted DatetimeIndex it needs.
Note: paths 1 and 2 both pay the per-bar Python cost (the strategy logic), so the
gap between them measures only the engine/broker plumbing. Path 3 shows the
engine's ceiling when nothing crosses into Python.
"""
import time
import pandas as pd
from backtesting import Backtest as BtBacktest, Strategy as BtStrategy
from backtestingfx import Backtest, Strategy
from backtestingfx import _backtestingfx as _rust
FAST = 10
SLOW = 50
LOT = 0.1
BARS = 50_000
REPEATS = 2
class SmaCrossBt(BtStrategy):
def init(self):
pass
def next(self):
if len(self.data.Close) < SLOW:
return
fast_sma = self.data.Close[-FAST:].mean()
slow_sma = self.data.Close[-SLOW:].mean()
if not self.position:
if fast_sma > slow_sma:
self.buy()
elif fast_sma < slow_sma:
self.position.close()
class SmaCrossFx(Strategy):
def next(self):
if self.index < SLOW:
return
closes = [b.close for b in self.data[-SLOW:]]
fast_sma = sum(closes[-FAST:]) / FAST
slow_sma = sum(closes) / SLOW
if not self.positions:
if fast_sma > slow_sma:
self.buy(LOT)
elif fast_sma < slow_sma:
self.close_all()
def load_tiled(n):
"""Load the EURUSD CSV and tile it up to n rows with a fresh hourly index."""
df = pd.read_csv("data/EURUSD_1H.csv")[["open", "high", "low", "close", "volume"]]
reps = -(-n // len(df)) # ceil division
df = pd.concat([df] * reps, ignore_index=True).iloc[:n].copy()
df["timestamp"] = pd.date_range("2000-01-01", periods=n, freq="h")
return df
def best_of(fn, repeats=REPEATS):
"""Run fn repeats times, return (best_seconds, result_of_first_run)."""
best = float("inf")
result = None
for i in range(repeats):
t0 = time.perf_counter()
r = fn()
dt = time.perf_counter() - t0
if i == 0:
result = r
best = min(best, dt)
return best, result
df = load_tiled(BARS)
# backtesting.py wants a DatetimeIndex and capitalised columns
df_bt = df.set_index("timestamp").rename(
columns={"open": "Open", "high": "High", "low": "Low", "close": "Close", "volume": "Volume"}
)
# native-rust path needs Bar objects built directly
bars = [
_rust.Bar(
timestamp=int(ts.timestamp()),
open=float(o), high=float(h), low=float(l), close=float(c), volume=float(v),
)
for ts, o, h, l, c, v in zip(
df["timestamp"], df["open"], df["high"], df["low"], df["close"], df["volume"]
)
]
def run_bt():
return BtBacktest(df_bt, SmaCrossBt, cash=10_000, commission=0).run()
def run_fx_py():
return Backtest(df, SmaCrossFx, cash=10_000, commission=0, spread=0).run()
def run_fx_rust():
engine = _rust.Engine(bars, 10_000.0, 0.0, 0.0, 100_000.0, 1.0)
return engine.run_native_sma(FAST, SLOW, LOT)
print(f"Benchmarking on {BARS:,} bars, best of {REPEATS} runs...\n", flush=True)
print(" timing backtesting.py ...", flush=True)
t_bt, r_bt = best_of(run_bt)
print(" timing backtestingfx (py next) ...", flush=True)
t_py, r_py = best_of(run_fx_py)
print(" timing backtestingfx (rust) ...\n", flush=True)
t_rs, r_rs = best_of(run_fx_rust)
print("=" * 62)
print(f"{'Path':<24}{'Time (s)':>10}{'Bars/sec':>14}{'Speedup':>12}")
print("=" * 62)
for name, t in [
("backtesting.py", t_bt),
("backtestingfx (py next)", t_py),
("backtestingfx (rust)", t_rs),
]:
print(f"{name:<24}{t:>10.3f}{BARS / t:>14,.0f}{t_bt / t:>11.1f}x")
print("=" * 62)
print("\nCorrectness (should roughly agree):")
print(f"{'Path':<24}{'Trades':>10}{'Return %':>12}")
print(f"{'backtesting.py':<24}{r_bt['# Trades']:>10}{r_bt['Return [%]']:>12.2f}")
print(f"{'backtestingfx (py next)':<24}{r_py.num_trades:>10}{r_py.total_return_pct:>12.2f}")
print(f"{'backtestingfx (rust)':<24}{r_rs.num_trades:>10}{r_rs.total_return_pct:>12.2f}")
print(
"\nNote: py-next vs rust measure the same engine; the gap is the per-bar\n"
"Python callback cost. Both use fixed lots, so Return % differs from\n"
"backtesting.py's cash-based sizing."
)
+40
View File
@@ -30,6 +30,35 @@ impl Engine {
} }
} }
// ponytail: benchmark-only native strategy, mirrors examples/compare_bt.py SmaCrossFx.
// Exists so we can time the pure-Rust engine path (no per-bar Python call) against run_py.
struct SmaCross {
fast: usize,
slow: usize,
lot_size: f64,
closes: Vec<f64>,
}
impl Strategy for SmaCross {
fn next(&mut self, bar: &Bar, broker: &mut Broker) {
self.closes.push(bar.close);
if self.closes.len() <= self.slow {
return;
}
let n = self.closes.len();
let fast_sma: f64 = self.closes[n - self.fast..].iter().sum::<f64>() / self.fast as f64;
let slow_sma: f64 = self.closes[n - self.slow..].iter().sum::<f64>() / self.slow as f64;
if broker.positions.is_empty() {
if fast_sma > slow_sma {
broker.buy(bar.close, self.lot_size, bar.timestamp, None, None);
}
} else if fast_sma < slow_sma {
broker.close_all(bar.close, bar.timestamp);
}
}
}
#[pymethods] #[pymethods]
impl Engine { impl Engine {
#[new] #[new]
@@ -95,6 +124,17 @@ impl Engine {
let b = broker_py.borrow(py); let b = broker_py.borrow(py);
Ok(Stats::compute(&b, &self.equity_curve)) Ok(Stats::compute(&b, &self.equity_curve))
} }
// ponytail: benchmark hook, runs the SMA strategy entirely in Rust via the native run() path.
pub fn run_native_sma(&mut self, fast: usize, slow: usize, lot_size: f64) -> Stats {
let mut strat = SmaCross {
fast,
slow,
lot_size,
closes: Vec::new(),
};
self.run(&mut strat)
}
} }
#[cfg(test)] #[cfg(test)]