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:
KhizarImran
2026-07-05 14:42:10 +01:00
parent 2af19afafa
commit c22153f02f
7 changed files with 182 additions and 15 deletions
+20
View File
@@ -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()
+90
View File
@@ -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.")
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -33,7 +33,7 @@ impl Bar {
}
}
#[pyclass]
#[pyclass(from_py_object)]
#[derive(Debug, Clone)]
pub struct Position {
// this is for the trading position