mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-08-05 00:07:46 +00:00
new: optimise.rs which handles optimisation. also linked into the python
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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::<engine::Engine>()?;
|
||||
m.add_class::<types::Position>()?;
|
||||
m.add_class::<types::Trade>()?;
|
||||
m.add_function(wrap_pyfunction!(optimise::run_grid, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+166
@@ -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<Vec<f64>>,
|
||||
) -> PyResult<Vec<Stats>> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user