diff --git a/backtestingfx/backtest.py b/backtestingfx/backtest.py index fe9de63..4994666 100644 --- a/backtestingfx/backtest.py +++ b/backtestingfx/backtest.py @@ -9,11 +9,28 @@ class Strategy: self._bars: Any = None self._bar: Any = None self._broker: Any = None + self._index: int = 0 @property def positions(self): return self._broker.positions() if self._broker else [] + @property + def data(self): + return self._bars[: self._index + 1] if self._bars else [] + + @property + def index(self): + return self._index + + @property + def cash(self): + return self._broker.cash if self._broker else 0.0 + + @property + def equity(self): + return self._broker.equity(self._bar.close) if self._broker else 0.0 + def init(self): pass @@ -40,6 +57,7 @@ class Strategy: class _Adapter: def __init__(self, strategy): self._strategy = strategy + self._index = 0 def init(self, bars): self._strategy._bars = bars @@ -48,6 +66,8 @@ class _Adapter: def next(self, bar, broker): self._strategy._bar = bar self._strategy._broker = broker + self._strategy._index = self._index + self._index += 1 self._strategy.next() diff --git a/examples/compare_bt.py b/examples/compare_bt.py new file mode 100644 index 0000000..8ae2c31 --- /dev/null +++ b/examples/compare_bt.py @@ -0,0 +1,90 @@ +""" +Compares our SMA crossover logic against backtesting.py using the same data, +same SMA periods, and zero commission/spread so only entry/exit logic is tested. + +Trade count and win rate should match between both libraries. +Dollar PnL will differ because the sizing models are different: + - backtesting.py: buys fractional units based on available cash + - backtestingfx: fixed lot size (0.1 lots = 10,000 units) +""" + +import pandas as pd +from backtesting import Backtest as BtBacktest, Strategy as BtStrategy +from backtestingfx import Backtest, Strategy + +FAST = 10 +SLOW = 50 + + +# ── backtesting.py ──────────────────────────────────────────────────────────── + +class SmaCrossBt(BtStrategy): + fast = FAST + slow = SLOW + + def init(self): + pass + + def next(self): + if len(self.data.Close) < self.slow: + return + fast_sma = self.data.Close[-self.fast:].mean() + slow_sma = self.data.Close[-self.slow:].mean() + + if not self.position: + if fast_sma > slow_sma: + self.buy() + else: + if fast_sma < slow_sma: + self.position.close() + + +# ── backtestingfx ───────────────────────────────────────────────────────────── + +class SmaCrossFx(Strategy): + fast = FAST + slow = SLOW + + def next(self): + if self.index < self.slow: + return + closes = [b.close for b in self.data[-self.slow:]] + fast_sma = sum(closes[-self.fast:]) / self.fast + slow_sma = sum(closes) / self.slow + + if not self.positions: + if fast_sma > slow_sma: + self.buy(0.1) + else: + if fast_sma < slow_sma: + self.close_all() + + +# ── run both ────────────────────────────────────────────────────────────────── + +df_raw = pd.read_csv("data/EURUSD_1H.csv") + +# backtesting.py needs a DatetimeIndex and capitalised column names +df_bt = df_raw.copy() +df_bt["timestamp"] = pd.to_datetime(df_bt["timestamp"], utc=True) +df_bt = df_bt.set_index("timestamp") +df_bt.index = df_bt.index.tz_localize(None) +df_bt = df_bt.rename(columns={"open": "Open", "high": "High", "low": "Low", "close": "Close", "volume": "Volume"}) + +bt_result = BtBacktest(df_bt, SmaCrossBt, cash=10_000, commission=0).run() + +fx_result = Backtest(df_raw, SmaCrossFx, cash=10_000, commission=0, spread=0).run() + +# ── compare ─────────────────────────────────────────────────────────────────── + +print("=" * 45) +print(f"{'Metric':<20} {'backtesting.py':>12} {'backtestingfx':>12}") +print("=" * 45) +print(f"{'Trades':<20} {bt_result['# Trades']:>12} {fx_result.num_trades:>12}") +print(f"{'Win Rate %':<20} {bt_result['Win Rate [%]']:>12.1f} {fx_result.win_rate_pct:>12.1f}") +print(f"{'Return %':<20} {bt_result['Return [%]']:>12.2f} {fx_result.total_return_pct:>12.2f}") +print(f"{'Max Drawdown %':<20} {bt_result['Max. Drawdown [%]']:>12.2f} {fx_result.max_drawdown_pct:>12.2f}") +print("=" * 45) +print() +print("Note: Return % differs because backtesting.py sizes by available cash,") +print(" backtestingfx uses fixed 0.1 lots. Trade count + win rate should match.") diff --git a/examples/sma_cross.py b/examples/sma_cross.py new file mode 100644 index 0000000..fd17843 --- /dev/null +++ b/examples/sma_cross.py @@ -0,0 +1,36 @@ +import pandas as pd +from backtestingfx import Backtest, Strategy + + +class SmaCross(Strategy): + fast = 10 + slow = 50 + + def next(self): + if self.index < self.slow: + return + + closes = [b.close for b in self.data[-self.slow :]] + fast_sma = sum(closes[-self.fast :]) / self.fast + slow_sma = sum(closes) / self.slow + + if not self.positions: + if fast_sma > slow_sma: + self.buy(0.1) + else: + if fast_sma < slow_sma: + self.close_all() + + +df = pd.read_csv("data/EURUSD_1H.csv") + +stats = Backtest( + df, + SmaCross, + cash=10_000, + commission=3.5, + spread=0.00002, + contract_size=100_000, +).run() + +print(stats) diff --git a/src/broker.rs b/src/broker.rs index 805f82c..0da0b5d 100644 --- a/src/broker.rs +++ b/src/broker.rs @@ -3,6 +3,7 @@ use pyo3::prelude::*; #[pyclass] pub struct Broker { + #[pyo3(get)] pub cash: f64, pub initial_cash: f64, next_id: u64, @@ -63,13 +64,14 @@ impl Broker { * self.contract_size * self.quote_to_account }; - self.cash += pnl - self.commission * position.lot_size; + let net_pnl = pnl - self.commission * position.lot_size; + self.cash += net_pnl; self.trade_history.push(Trade { entry_price: position.entry_price, exit_price: close_price, lot_size: position.lot_size, is_long: position.is_long, - pnl, + pnl: net_pnl, entry_timestamp: position.entry_timestamp, exit_timestamp: bar.timestamp, }); @@ -175,12 +177,13 @@ impl Broker { * self.quote_to_account }; - self.cash += pnl - self.commission * position.lot_size; + let net_pnl = pnl - self.commission * position.lot_size; + self.cash += net_pnl; self.trade_history.push(Trade { entry_price: position.entry_price, lot_size: position.lot_size, is_long: position.is_long, - pnl, + pnl: net_pnl, entry_timestamp: position.entry_timestamp, exit_timestamp: timestamp, exit_price: close_price, @@ -207,12 +210,13 @@ impl Broker { * self.contract_size * self.quote_to_account }; - self.cash += pnl - self.commission * position.lot_size; + let net_pnl = pnl - self.commission * position.lot_size; + self.cash += net_pnl; self.trade_history.push(Trade { entry_price: position.entry_price, lot_size: position.lot_size, is_long: position.is_long, - pnl, + pnl: net_pnl, entry_timestamp: position.entry_timestamp, exit_timestamp: timestamp, exit_price: close_price, diff --git a/src/engine.rs b/src/engine.rs index 2953071..f590812 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -54,12 +54,7 @@ impl Engine { pub fn run_py(&mut self, py: Python<'_>, strategy: Py) -> PyResult { self.equity_curve.clear(); - let init_result = strategy.bind(py).call_method1("init", (self.data.clone(),)); - if let Err(e) = init_result { - if !e.is_instance_of::(py) { - return Err(e); - } - } + strategy.bind(py).call_method1("init", (self.data.clone(),))?; let broker_py = Py::new( py, diff --git a/src/stats.rs b/src/stats.rs index 45414cd..4383ad3 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -25,6 +25,24 @@ pub struct Stats { pub profit_factor: f64, #[pyo3(get)] pub max_drawdown_pct: f64, + #[pyo3(get)] + pub sharpe_ratio: f64, +} + +fn sharpe_ratio(equity_curve: &[f64]) -> f64 { + if equity_curve.len() < 2 { + return 0.0; + } + let returns: Vec = equity_curve + .windows(2) + .map(|w| (w[1] - w[0]) / w[0]) + .collect(); + let mean = returns.iter().sum::() / returns.len() as f64; + let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::() / returns.len() as f64; + if variance == 0.0 { + return 0.0; + } + mean / variance.sqrt() } fn max_drawdown(equity_curve: &[f64]) -> f64 { @@ -101,6 +119,7 @@ impl Stats { f64::INFINITY }; let max_drawdown_pct = max_drawdown(equity_curve); + let sharpe_ratio = sharpe_ratio(equity_curve); Stats { initial_cash, @@ -114,6 +133,7 @@ impl Stats { worst_trade: if num_trades > 0 { worst_trade } else { 0.0 }, profit_factor, max_drawdown_pct, + sharpe_ratio, } } } @@ -132,7 +152,8 @@ impl std::fmt::Display for Stats { Best Trade: {:.5}\n\ Worst Trade: {:.5}\n\ Profit Factor: {:.2}\n\ - Max Drawdown: {:.2}%", + Max Drawdown: {:.2}%\n\ + Sharpe Ratio: {:.4} (unannualized)", self.initial_cash, self.final_cash, self.total_return_pct, @@ -142,7 +163,8 @@ impl std::fmt::Display for Stats { self.best_trade, self.worst_trade, self.profit_factor, - self.max_drawdown_pct + self.max_drawdown_pct, + self.sharpe_ratio ) } } diff --git a/src/types.rs b/src/types.rs index a883f59..600ba9b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -33,7 +33,7 @@ impl Bar { } } -#[pyclass] +#[pyclass(from_py_object)] #[derive(Debug, Clone)] pub struct Position { // this is for the trading position