mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-08-17 14:08:05 +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._bars: Any = None
|
||||||
self._bar: Any = None
|
self._bar: Any = None
|
||||||
self._broker: Any = None
|
self._broker: Any = None
|
||||||
|
self._index: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def positions(self):
|
def positions(self):
|
||||||
return self._broker.positions() if self._broker else []
|
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):
|
def init(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -40,6 +57,7 @@ class Strategy:
|
|||||||
class _Adapter:
|
class _Adapter:
|
||||||
def __init__(self, strategy):
|
def __init__(self, strategy):
|
||||||
self._strategy = strategy
|
self._strategy = strategy
|
||||||
|
self._index = 0
|
||||||
|
|
||||||
def init(self, bars):
|
def init(self, bars):
|
||||||
self._strategy._bars = bars
|
self._strategy._bars = bars
|
||||||
@@ -48,6 +66,8 @@ class _Adapter:
|
|||||||
def next(self, bar, broker):
|
def next(self, bar, broker):
|
||||||
self._strategy._bar = bar
|
self._strategy._bar = bar
|
||||||
self._strategy._broker = broker
|
self._strategy._broker = broker
|
||||||
|
self._strategy._index = self._index
|
||||||
|
self._index += 1
|
||||||
self._strategy.next()
|
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]
|
#[pyclass]
|
||||||
pub struct Broker {
|
pub struct Broker {
|
||||||
|
#[pyo3(get)]
|
||||||
pub cash: f64,
|
pub cash: f64,
|
||||||
pub initial_cash: f64,
|
pub initial_cash: f64,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
@@ -63,13 +64,14 @@ impl Broker {
|
|||||||
* self.contract_size
|
* self.contract_size
|
||||||
* self.quote_to_account
|
* 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 {
|
self.trade_history.push(Trade {
|
||||||
entry_price: position.entry_price,
|
entry_price: position.entry_price,
|
||||||
exit_price: close_price,
|
exit_price: close_price,
|
||||||
lot_size: position.lot_size,
|
lot_size: position.lot_size,
|
||||||
is_long: position.is_long,
|
is_long: position.is_long,
|
||||||
pnl,
|
pnl: net_pnl,
|
||||||
entry_timestamp: position.entry_timestamp,
|
entry_timestamp: position.entry_timestamp,
|
||||||
exit_timestamp: bar.timestamp,
|
exit_timestamp: bar.timestamp,
|
||||||
});
|
});
|
||||||
@@ -175,12 +177,13 @@ impl Broker {
|
|||||||
* self.quote_to_account
|
* 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 {
|
self.trade_history.push(Trade {
|
||||||
entry_price: position.entry_price,
|
entry_price: position.entry_price,
|
||||||
lot_size: position.lot_size,
|
lot_size: position.lot_size,
|
||||||
is_long: position.is_long,
|
is_long: position.is_long,
|
||||||
pnl,
|
pnl: net_pnl,
|
||||||
entry_timestamp: position.entry_timestamp,
|
entry_timestamp: position.entry_timestamp,
|
||||||
exit_timestamp: timestamp,
|
exit_timestamp: timestamp,
|
||||||
exit_price: close_price,
|
exit_price: close_price,
|
||||||
@@ -207,12 +210,13 @@ impl Broker {
|
|||||||
* self.contract_size
|
* self.contract_size
|
||||||
* self.quote_to_account
|
* 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 {
|
self.trade_history.push(Trade {
|
||||||
entry_price: position.entry_price,
|
entry_price: position.entry_price,
|
||||||
lot_size: position.lot_size,
|
lot_size: position.lot_size,
|
||||||
is_long: position.is_long,
|
is_long: position.is_long,
|
||||||
pnl,
|
pnl: net_pnl,
|
||||||
entry_timestamp: position.entry_timestamp,
|
entry_timestamp: position.entry_timestamp,
|
||||||
exit_timestamp: timestamp,
|
exit_timestamp: timestamp,
|
||||||
exit_price: close_price,
|
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> {
|
pub fn run_py(&mut self, py: Python<'_>, strategy: Py<PyAny>) -> PyResult<Stats> {
|
||||||
self.equity_curve.clear();
|
self.equity_curve.clear();
|
||||||
|
|
||||||
let init_result = strategy.bind(py).call_method1("init", (self.data.clone(),));
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let broker_py = Py::new(
|
let broker_py = Py::new(
|
||||||
py,
|
py,
|
||||||
|
|||||||
+24
-2
@@ -25,6 +25,24 @@ pub struct Stats {
|
|||||||
pub profit_factor: f64,
|
pub profit_factor: f64,
|
||||||
#[pyo3(get)]
|
#[pyo3(get)]
|
||||||
pub max_drawdown_pct: f64,
|
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 {
|
fn max_drawdown(equity_curve: &[f64]) -> f64 {
|
||||||
@@ -101,6 +119,7 @@ impl Stats {
|
|||||||
f64::INFINITY
|
f64::INFINITY
|
||||||
};
|
};
|
||||||
let max_drawdown_pct = max_drawdown(equity_curve);
|
let max_drawdown_pct = max_drawdown(equity_curve);
|
||||||
|
let sharpe_ratio = sharpe_ratio(equity_curve);
|
||||||
|
|
||||||
Stats {
|
Stats {
|
||||||
initial_cash,
|
initial_cash,
|
||||||
@@ -114,6 +133,7 @@ impl Stats {
|
|||||||
worst_trade: if num_trades > 0 { worst_trade } else { 0.0 },
|
worst_trade: if num_trades > 0 { worst_trade } else { 0.0 },
|
||||||
profit_factor,
|
profit_factor,
|
||||||
max_drawdown_pct,
|
max_drawdown_pct,
|
||||||
|
sharpe_ratio,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +152,8 @@ impl std::fmt::Display for Stats {
|
|||||||
Best Trade: {:.5}\n\
|
Best Trade: {:.5}\n\
|
||||||
Worst Trade: {:.5}\n\
|
Worst Trade: {:.5}\n\
|
||||||
Profit Factor: {:.2}\n\
|
Profit Factor: {:.2}\n\
|
||||||
Max Drawdown: {:.2}%",
|
Max Drawdown: {:.2}%\n\
|
||||||
|
Sharpe Ratio: {:.4} (unannualized)",
|
||||||
self.initial_cash,
|
self.initial_cash,
|
||||||
self.final_cash,
|
self.final_cash,
|
||||||
self.total_return_pct,
|
self.total_return_pct,
|
||||||
@@ -142,7 +163,8 @@ impl std::fmt::Display for Stats {
|
|||||||
self.best_trade,
|
self.best_trade,
|
||||||
self.worst_trade,
|
self.worst_trade,
|
||||||
self.profit_factor,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Position {
|
pub struct Position {
|
||||||
// this is for the trading position
|
// this is for the trading position
|
||||||
|
|||||||
Reference in New Issue
Block a user