diff --git a/examples/benchmark.py b/examples/benchmark.py new file mode 100644 index 0000000..f3c3482 --- /dev/null +++ b/examples/benchmark.py @@ -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." +) diff --git a/src/engine.rs b/src/engine.rs index 189c300..4ba1a56 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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, +} + +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::() / self.fast as f64; + let slow_sma: f64 = self.closes[n - self.slow..].iter().sum::() / 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)]