diff --git a/CHANGELOG.md b/CHANGELOG.md index a14af03..b997294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +### Added +- `Backtest.optimize()` — parallel grid search over a vectorised signal function. + Simulations run on native threads with the GIL released (`src/optimise.rs`); + 157x faster than looping `run()` over the same grid. +- `strategy_class` is now optional, so `Backtest(df, cash=...)` works for optimization +- Standalone HTML reports (`Backtest.plot()`) and `examples/html_report.py` +- Three-way speed benchmark against backtesting.py (`examples/benchmark.py`) + +### Fixed +- Data access inside `next()` is O(1) per bar instead of O(n) — ~2.6x faster + ## [0.1.1] - 2026-07-05 ### Added diff --git a/README.md b/README.md index f392b7c..40d135b 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,42 @@ self._bar.volume self._bar.timestamp # unix timestamp (int) ``` +## Optimization + +Grid-search parameters with the simulations running in parallel Rust threads: + +```python +import numpy as np +from backtestingfx import Backtest + +def sma_cross(df, fast, slow): + fast_sma = df["close"].rolling(fast).mean() + slow_sma = df["close"].rolling(slow).mean() + return np.where(fast_sma > slow_sma, 0.1, 0.0) # target lots per bar + +bt = Backtest(df, cash=10_000, commission=3.5) +results = bt.optimize(sma_cross, maximize="total_return_pct", + fast=range(5, 26), slow=range(30, 101, 5)) + +best_params, best_stats = results[0] +``` + +`optimize()` returns `[(params, stats), ...]` sorted best-first by the named `Stats` +field. Re-sort it yourself to minimise something instead. + +### Why a signal function instead of `next()` + +`next()` runs in Python, so every bar needs the GIL and threads can't help. A signal +function is called **once per parameter combination**, not once per bar — it returns the +target lot size for each bar (positive long, negative short, `0.0` flat), and Rust runs +every simulation natively with the GIL released. On a 315-combination grid that is +**157x faster** than looping `Backtest.run()` over the same grid. + +The trade-off: a signal function can't see the broker, so path-dependent logic (trailing +stops, pyramiding, "exit after N bars") still needs `next()` and a plain loop. Indicator +warmup must come out as `0.0`, not `NaN` — a `NaN` signal is rejected rather than +silently treated as "hold". + ## Backtest Parameters ```python diff --git a/backtestingfx/backtest.py b/backtestingfx/backtest.py index 12b73b1..be182cf 100644 --- a/backtestingfx/backtest.py +++ b/backtestingfx/backtest.py @@ -1,6 +1,8 @@ +import itertools from typing import Any from backtestingfx import _backtestingfx as _rust # type: ignore +import numpy as np import pandas as pd @@ -107,7 +109,7 @@ class Backtest: def __init__( self, df, - strategy_class, + strategy_class=None, # optional: optimize() uses a signal function instead cash=10000.0, commission=0.0, spread=0.0, @@ -149,24 +151,68 @@ class Backtest: ) return bars - def run(self): - self._stats = None - self._report_df = None - report_df = self._df.copy(deep=True) - bars = self._to_bars(report_df) - engine = _rust.Engine( # type: ignore - bars, + def _engine(self, df): + return _rust.Engine( # type: ignore + self._to_bars(df), self._cash, self._commission, self._spread, self._contract_size, self._quote_to_account, ) + + def run(self): + if self._strategy_class is None: + raise ValueError("Backtest needs a strategy_class to run(); use optimize() for signal functions") + + self._stats = None + self._report_df = None + report_df = self._df.copy(deep=True) + engine = self._engine(report_df) strategy = self._strategy_class() self._stats = engine.run(_Adapter(strategy)) self._report_df = report_df return self._stats + def optimize(self, signal_fn, maximize="total_return_pct", **grid): + """Grid-search `signal_fn` over the given parameter ranges. Best result first. + + `signal_fn(df, **params)` is called once per combination and returns one target + lot size per bar: positive for long, negative for short, 0.0 for flat. Write it + vectorised (pandas/numpy) — it runs in Python, but only once per combination, + never per bar. The simulations themselves run in parallel Rust threads. + + Returns a list of `(params, stats)` sorted by the named Stats field, so + `results[0]` is the best run. Sort it yourself to minimise something instead. + """ + if not grid: + raise ValueError("optimize needs at least one parameter range") + + names = list(grid) + combos = [dict(zip(names, values)) for values in itertools.product(*grid.values())] + + signals = [] + for combo in combos: + signal = np.asarray(signal_fn(self._df, **combo), dtype=float) + if np.isnan(signal).any(): + raise ValueError( + f"signal_fn returned NaN for {combo} — indicator warmup should " + "produce 0.0 (flat), not NaN" + ) + # ponytail: tolist() is the cheap bridge into Rust. It costs one Python + # float per bar per combo; swap in the `numpy` crate for a zero-copy + # PyReadonlyArray1 if this ever shows up in a profile. + signals.append(signal.tolist()) + + engine = self._engine(self._df) + results = _rust.run_grid(engine, signals) # type: ignore + + return sorted( + zip(combos, results), + key=lambda pair: getattr(pair[1], maximize), + reverse=True, + ) + def plot(self, filename="backtest.html", open_browser=True): if self._stats is None: raise RuntimeError("Run the backtest before plotting it") diff --git a/examples/optimize.py b/examples/optimize.py new file mode 100644 index 0000000..0276d5e --- /dev/null +++ b/examples/optimize.py @@ -0,0 +1,58 @@ +""" +Grid-search an SMA crossover over fast/slow periods. + +The strategy is written as a *signal function* rather than a Strategy subclass: +it returns the target lot size for every bar in one vectorised pass. That runs +once per parameter combination, so the per-bar work happens entirely in Rust +and the whole grid runs on parallel threads. +""" + +import time + +import numpy as np +import pandas as pd + +from backtestingfx import Backtest + +LOT = 0.1 + + +def sma_cross(df, fast, slow): + """Long LOT lots while the fast SMA is above the slow one, otherwise flat.""" + fast_sma = df["close"].rolling(fast).mean() + slow_sma = df["close"].rolling(slow).mean() + # NaN during warmup compares False, so the untradeable head comes out flat + return np.where(fast_sma > slow_sma, LOT, 0.0) + + +df = pd.read_csv("data/EURUSD_1H.csv") + +backtest = Backtest(df, cash=10_000, commission=3.5, spread=0.00002) + +fast_range = range(5, 26) +slow_range = range(30, 101, 5) +combos = len(fast_range) * len(slow_range) + +start = time.perf_counter() +results = backtest.optimize( + sma_cross, + maximize="total_return_pct", + fast=fast_range, + slow=slow_range, +) +elapsed = time.perf_counter() - start + +print(f"{combos} combinations over {len(df):,} bars in {elapsed:.2f}s " + f"({combos * len(df) / elapsed / 1e6:.1f}M bar-sims/sec)\n") + +print(f"{'fast':>6}{'slow':>6}{'return %':>12}{'trades':>9}{'win %':>8}{'max dd %':>10}") +print("-" * 51) +for params, stats in results[:10]: + print( + f"{params['fast']:>6}{params['slow']:>6}{stats.total_return_pct:>12.2f}" + f"{stats.num_trades:>9}{stats.win_rate_pct:>8.1f}{stats.max_drawdown_pct:>10.2f}" + ) + +best_params, best_stats = results[0] +print(f"\nBest: {best_params}") +print(best_stats) diff --git a/pyproject.toml b/pyproject.toml index 2619179..0e8a0e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "backtestingfx" version = "0.1.1" description = "FX backtesting library built in Rust" requires-python = ">=3.9" -dependencies = ["pandas"] +dependencies = ["pandas", "numpy"] license = { text = "MIT" } readme = "README.md" diff --git a/src/lib.rs b/src/lib.rs index 6473d37..9c4718a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod broker; pub mod data; pub mod engine; +pub mod optimise; pub mod stats; pub mod strategy; pub mod types; @@ -16,5 +17,6 @@ fn backtestingfx(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_function(wrap_pyfunction!(optimise::run_grid, m)?)?; Ok(()) } diff --git a/src/optimise.rs b/src/optimise.rs new file mode 100644 index 0000000..448c404 --- /dev/null +++ b/src/optimise.rs @@ -0,0 +1,166 @@ +//! Parameter optimisation: run many backtests in parallel, natively. +//! +//! The strategy callback can't be used here — it runs in Python, so every bar would +//! need the GIL and the threads below would just queue up behind each other. Instead +//! Python sends one *signal array* per parameter combination: the target lot size for +//! each bar (+0.1 long, -0.1 short, 0.0 flat). Producing those is cheap and vectorised +//! in Python; consuming them is pure Rust, so the GIL can be released and the whole +//! grid runs on real threads. + +use crate::broker::Broker; +use crate::engine::Engine; +use crate::stats::Stats; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// One simulation, driven by target lots per bar. +/// +/// Takes `&Engine`, never `&mut`. That's the whole trick: with a shared reference it +/// *has* to build its own Broker, which is what makes it safe to run many of these +/// across threads on one Engine. (It also means, unlike `Engine::run`, that calling +/// it twice gives the same answer twice.) +fn simulate(engine: &Engine, target: &[f64]) -> Stats { + let mut broker = Broker::new( + engine.broker.initial_cash, + engine.broker.commission, + engine.broker.spread, + engine.broker.contract_size, + engine.broker.quote_to_account, + ); + let mut equity_curve = Vec::with_capacity(engine.data.len() + 1); + equity_curve.push(broker.initial_cash); + + for (bar, &want) in engine.data.iter().zip(target) { + broker.check_sl_tp(bar); + + // signed exposure: longs count positive, shorts negative + let have: f64 = broker + .positions + .iter() + .map(|p| if p.is_long { p.lot_size } else { -p.lot_size }) + .sum(); + + // ponytail: any change in target flattens and reopens, so a resize pays + // commission on the full size. Adjust the existing position instead if that + // drag starts distorting the grid. + if (want - have).abs() > 1e-9 { + broker.close_all(bar.close, bar.timestamp); + if want > 0.0 { + broker.buy(bar.close, want, bar.timestamp, None, None); + } else if want < 0.0 { + broker.sell(bar.close, -want, bar.timestamp, None, None); + } + } + + equity_curve.push(broker.equity(bar.close)); + } + + // match Engine::run: liquidate at the last bar and let that land in the curve + if let Some(last_bar) = engine.data.last() { + broker.close_all(last_bar.close, last_bar.timestamp); + *equity_curve.last_mut().unwrap() = broker.cash; + } + + Stats::compute(&broker, &equity_curve) +} + +/// Run one simulation per signal array, in parallel, and return the Stats in order. +/// +/// A free function rather than a method because PyO3 allows only one `#[pymethods]` +/// block per class without the `multiple-pymethods` feature, and Engine already has one. +#[pyfunction] +pub fn run_grid( + py: Python<'_>, + engine: PyRef<'_, Engine>, + signals: Vec>, +) -> PyResult> { + let bars = engine.data.len(); + if let Some(bad) = signals.iter().position(|s| s.len() != bars) { + return Err(PyValueError::new_err(format!( + "signal {} has {} values, expected {} (one per bar)", + bad, + signals[bad].len(), + bars + ))); + } + + let engine: &Engine = &engine; + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()); + let chunk = signals.len().div_ceil(threads).max(1); + + // detach drops the GIL for the duration (it was `allow_threads` before PyO3 0.28). + // Without it the threads below spawn and immediately block, and the whole exercise + // buys nothing. + Ok(py.detach(|| { + // scope lets the threads borrow `engine` and `signals` directly: it guarantees + // they finish before it returns, so no Arc and no cloning the bars. + std::thread::scope(|scope| { + let handles: Vec<_> = signals + .chunks(chunk) + .map(|slice| { + scope.spawn(move || { + slice.iter().map(|s| simulate(engine, s)).collect::>() + }) + }) + .collect(); + + handles + .into_iter() + .flat_map(|h| h.join().unwrap()) + .collect() + }) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Bar; + + // two bars, price rises 1.1000 -> 1.2000, no costs + fn test_engine() -> Engine { + let data = vec![ + Bar::new(0, 1.1, 1.1, 1.1, 1.1, 0.0), + Bar::new(3600, 1.2, 1.2, 1.2, 1.2, 0.0), + ]; + Engine::new(data, 10_000.0, 0.0, 0.0, 100_000.0, 1.0) + } + + #[test] + fn signal_opens_holds_and_liquidates() { + let engine = test_engine(); + let stats = simulate(&engine, &[1.0, 1.0]); + + // bought 1 lot at 1.1000, held through bar 2, liquidated at 1.2000 + // (1.2 - 1.1) * 1.0 * 100_000 = 10_000 + assert_eq!(stats.num_trades, 1); + assert!((stats.final_cash - 20_000.0).abs() < 1e-6); + } + + #[test] + fn flat_signal_never_trades() { + let stats = simulate(&test_engine(), &[0.0, 0.0]); + + assert_eq!(stats.num_trades, 0); + assert_eq!(stats.final_cash, 10_000.0); + } + + #[test] + fn short_signal_loses_when_price_rises() { + let stats = simulate(&test_engine(), &[-1.0, -1.0]); + + assert!((stats.final_cash - 0.0).abs() < 1e-6); // lost the whole 10_000 + } + + #[test] + fn each_run_gets_a_fresh_broker() { + let engine = test_engine(); + + // the bug this design rules out: state leaking from one run into the next + let first = simulate(&engine, &[1.0, 1.0]); + let second = simulate(&engine, &[1.0, 1.0]); + + assert_eq!(first.final_cash, second.final_cash); + assert_eq!(second.num_trades, 1); + } +} diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 3158ca5..f6b2c1d 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -76,5 +76,71 @@ class BacktestTest(unittest.TestCase): self.assertIn("2026-01-01 01:00", contents) +def rising_market(bars=20): + closes = [1.1000 + 0.0010 * i for i in range(bars)] + return pd.DataFrame( + {"open": closes, "high": closes, "low": closes, "close": closes}, + index=pd.date_range("2026-01-01", periods=bars, freq="h", tz="UTC"), + ) + + +class OptimizeTest(unittest.TestCase): + def test_grid_runs_every_combo_and_ranks_by_metric(self): + def hold_lots(df, lots): + return [lots] * len(df) + + backtest = Backtest(rising_market(), cash=10_000.0) + results = backtest.optimize(hold_lots, lots=[0.1, 0.5, 1.0]) + + self.assertEqual(len(results), 3) + # price only rises, so the biggest long wins and ranking is strictly descending + self.assertEqual([params["lots"] for params, _ in results], [1.0, 0.5, 0.1]) + returns = [stats.total_return_pct for _, stats in results] + self.assertEqual(returns, sorted(returns, reverse=True)) + + def test_grid_is_the_cartesian_product_and_matches_a_single_run(self): + def hold_lots(df, lots, unused): + return [lots] * len(df) + + backtest = Backtest(rising_market(), cash=10_000.0) + results = backtest.optimize(hold_lots, lots=[0.1, 0.2], unused=["a", "b"]) + self.assertEqual(len(results), 4) + + # a parallel grid run must agree with the same signal run on its own + alone = backtest.optimize(hold_lots, lots=[0.2], unused=["a"]) + matching = [s for p, s in results if p == {"lots": 0.2, "unused": "a"}] + self.assertEqual(matching[0].final_cash, alone[0][1].final_cash) + + def test_maximize_picks_the_named_field(self): + def hold_lots(df, lots): + return [lots] * len(df) + + backtest = Backtest(rising_market(), cash=10_000.0) + results = backtest.optimize(hold_lots, maximize="max_drawdown_pct", lots=[0.1, 1.0]) + + self.assertEqual( + [stats.max_drawdown_pct for _, stats in results], + sorted([stats.max_drawdown_pct for _, stats in results], reverse=True), + ) + + def test_nan_signal_is_rejected_rather_than_silently_held(self): + def leaky_warmup(df, lots): + return [float("nan")] + [lots] * (len(df) - 1) + + backtest = Backtest(rising_market(), cash=10_000.0) + with self.assertRaisesRegex(ValueError, "NaN"): + backtest.optimize(leaky_warmup, lots=[0.1]) + + def test_wrong_length_signal_is_rejected(self): + backtest = Backtest(rising_market(), cash=10_000.0) + with self.assertRaisesRegex(ValueError, "one per bar"): + backtest.optimize(lambda df, lots: [lots] * 3, lots=[0.1]) + + def test_empty_grid_is_rejected(self): + backtest = Backtest(rising_market(), cash=10_000.0) + with self.assertRaises(ValueError): + backtest.optimize(lambda df: []) + + if __name__ == "__main__": unittest.main()