use pine_broker::Trade;
use pine_core::Timeframe;
const MS_PER_YEAR: f64 = 365.0 * 24.0 * 60.0 * 60.0 * 1000.0;
#[derive(Debug, Clone, Default)]
pub struct Backtest {
pub initial_capital: f64,
pub equity: Vec<f64>,
pub trades: Vec<Trade>,
pub net_profit: f64,
pub open_profit: f64,
pub gross_profit: f64,
pub gross_loss: f64,
pub max_drawdown: f64,
pub max_runup: f64,
pub win_trades: usize,
pub loss_trades: usize,
pub even_trades: usize,
pub position_size: f64,
pub mark_price: f64,
pub halted: Option<u64>,
pub timeframe: Timeframe,
}
impl Backtest {
pub fn final_equity(&self) -> f64 {
self.equity.last().copied().unwrap_or(self.initial_capital)
}
pub fn closed_trades(&self) -> impl Iterator<Item = &Trade> {
self.trades.iter().filter(|t| !t.is_open())
}
pub fn open_trades(&self) -> impl Iterator<Item = &Trade> {
self.trades.iter().filter(|t| t.is_open())
}
pub fn generate_metrics(&self) -> Metrics {
let trades = self.win_trades + self.loss_trades + self.even_trades;
let final_equity = self.final_equity();
let max_drawdown = max_drawdown_percent(&self.equity);
let bars_per_year = bars_per_year(&self.timeframe);
let years = ratio(self.equity.len() as f64, bars_per_year);
let annual_return = if years > 0.0 && final_equity > 0.0 && self.initial_capital > 0.0 {
(final_equity / self.initial_capital).powf(1.0 / years) - 1.0
} else {
0.0
};
let returns = bar_returns(&self.equity);
let (mean, deviation) = mean_and_deviation(&returns);
let annualise = bars_per_year.sqrt();
Metrics {
bars: self.equity.len(),
initial_capital: self.initial_capital,
final_equity,
net_profit: self.net_profit,
total_return: ratio(final_equity - self.initial_capital, self.initial_capital),
annual_return,
max_drawdown,
sharpe: ratio(mean * annualise, deviation),
sortino: ratio(mean * annualise, downside_deviation(&returns)),
calmar: ratio(annual_return, max_drawdown),
trades,
wins: self.win_trades,
losses: self.loss_trades,
win_rate: ratio(self.win_trades as f64, trades as f64),
profit_factor: ratio(self.gross_profit, self.gross_loss),
avg_trade: ratio(self.net_profit, trades as f64),
exposure: exposure(&self.trades, self.equity.len()),
}
}
}
#[derive(Debug, Clone)]
pub struct Metrics {
pub bars: usize,
pub initial_capital: f64,
pub final_equity: f64,
pub net_profit: f64,
pub total_return: f64,
pub annual_return: f64,
pub max_drawdown: f64,
pub sharpe: f64,
pub sortino: f64,
pub calmar: f64,
pub trades: usize,
pub wins: usize,
pub losses: usize,
pub win_rate: f64,
pub profit_factor: f64,
pub avg_trade: f64,
pub exposure: f64,
}
fn ratio(numerator: f64, denominator: f64) -> f64 {
if denominator > 0.0 && denominator.is_finite() {
numerator / denominator
} else {
0.0
}
}
fn max_drawdown_percent(equity: &[f64]) -> f64 {
let mut peak = f64::NEG_INFINITY;
let mut worst = 0.0f64;
for &value in equity {
peak = peak.max(value);
if peak > 0.0 {
worst = worst.max((peak - value) / peak);
}
}
worst
}
fn exposure(trades: &[Trade], bars: usize) -> f64 {
if bars == 0 {
return 0.0;
}
let last = bars.saturating_sub(1) as u64;
let held: u64 = trades
.iter()
.map(|t| t.exit_bar.unwrap_or(last).saturating_sub(t.entry_bar))
.sum();
held as f64 / bars as f64
}
fn bars_per_year(tf: &Timeframe) -> f64 {
match tf.to_millis() {
Some(ms) if ms > 0 => MS_PER_YEAR / ms as f64,
_ => 0.0,
}
}
fn bar_returns(equity: &[f64]) -> Vec<f64> {
equity
.windows(2)
.filter(|pair| pair[0] > 0.0)
.map(|pair| pair[1] / pair[0] - 1.0)
.collect()
}
fn mean_and_deviation(returns: &[f64]) -> (f64, f64) {
if returns.is_empty() {
return (0.0, 0.0);
}
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;
(mean, variance.sqrt())
}
fn downside_deviation(returns: &[f64]) -> f64 {
if returns.is_empty() {
return 0.0;
}
let sum: f64 = returns
.iter()
.filter(|r| **r < 0.0)
.map(|r| r.powi(2))
.sum();
(sum / returns.len() as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ratios_over_nothing_are_zero() {
assert_eq!(ratio(1.0, 0.0), 0.0);
assert_eq!(ratio(0.0, 0.0), 0.0);
}
#[test]
fn max_drawdown_percent_is_measured_from_the_peak() {
assert_eq!(max_drawdown_percent(&[100.0, 200.0, 100.0, 150.0]), 0.5);
assert_eq!(max_drawdown_percent(&[100.0, 110.0, 120.0]), 0.0);
}
#[test]
fn generate_metrics_derives_the_summary() {
let b = Backtest {
initial_capital: 1000.0,
equity: vec![1000.0, 1100.0, 1200.0],
gross_profit: 200.0,
gross_loss: 100.0,
net_profit: 100.0,
win_trades: 3,
loss_trades: 1,
even_trades: 0,
..Default::default()
};
let m = b.generate_metrics();
assert_eq!(m.bars, 3);
assert!((m.total_return - 0.2).abs() < 1e-12); assert_eq!(m.profit_factor, 2.0);
assert_eq!(m.win_rate, 0.75); assert_eq!(m.avg_trade, 25.0); assert_eq!(m.trades, 4);
}
#[test]
fn annualises_from_the_timeframe() {
let equity: Vec<f64> = (0..365)
.map(|i| 1000.0 + 1000.0 * i as f64 / 364.0)
.collect();
let m = Backtest {
initial_capital: 1000.0,
equity,
..Default::default()
}
.generate_metrics();
assert!((m.annual_return - 1.0).abs() < 1e-9);
assert!(m.sharpe.is_finite() && m.sharpe > 0.0);
}
}