mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-07-27 20:17:44 +00:00
feat: strategy data access, sharpe ratio, and correctness fixes
- Trade.pnl now stores net pnl (after exit commission) so per-trade stats are accurate - Position pyclass uses from_py_object to fix deprecation warning - Removed dead AttributeError swallow in engine.rs - Strategy gains self.data, self.index, self.cash, self.equity properties - Broker.cash exposed to Python via pyo3(get) - Sharpe ratio added to Stats (unannualized) - Added examples/sma_cross.py and examples/compare_bt.py - Logic verified against backtesting.py: 34 trades, 29.4% win rate match
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
+10
-6
@@ -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,
|
||||
|
||||
+1
-6
@@ -54,12 +54,7 @@ impl Engine {
|
||||
pub fn run_py(&mut self, py: Python<'_>, strategy: Py<PyAny>) -> PyResult<Stats> {
|
||||
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::<pyo3::exceptions::PyAttributeError>(py) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
strategy.bind(py).call_method1("init", (self.data.clone(),))?;
|
||||
|
||||
let broker_py = Py::new(
|
||||
py,
|
||||
|
||||
+24
-2
@@ -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<f64> = equity_curve
|
||||
.windows(2)
|
||||
.map(|w| (w[1] - w[0]) / w[0])
|
||||
.collect();
|
||||
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
|
||||
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ impl Bar {
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
#[pyclass(from_py_object)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Position {
|
||||
// this is for the trading position
|
||||
|
||||
Reference in New Issue
Block a user