mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-07-27 20:17:44 +00:00
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:
@@ -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."
|
||||
)
|
||||
@@ -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]
|
||||
impl Engine {
|
||||
#[new]
|
||||
@@ -95,6 +124,17 @@ impl Engine {
|
||||
let b = broker_py.borrow(py);
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user