1use pine_broker::Trade;
4use pine_core::Timeframe;
5
6const MS_PER_YEAR: f64 = 365.0 * 24.0 * 60.0 * 60.0 * 1000.0;
8
9#[derive(Debug, Clone, Default)]
12pub struct Backtest {
13 pub initial_capital: f64,
14 pub equity: Vec<f64>,
16 pub trades: Vec<Trade>,
19 pub net_profit: f64,
20 pub open_profit: f64,
21 pub gross_profit: f64,
22 pub gross_loss: f64,
24 pub max_drawdown: f64,
25 pub max_runup: f64,
26 pub win_trades: usize,
27 pub loss_trades: usize,
28 pub even_trades: usize,
29 pub position_size: f64,
31 pub mark_price: f64,
33 pub halted: Option<u64>,
36 pub timeframe: Timeframe,
38}
39
40impl Backtest {
41 pub fn final_equity(&self) -> f64 {
43 self.equity.last().copied().unwrap_or(self.initial_capital)
44 }
45
46 pub fn closed_trades(&self) -> impl Iterator<Item = &Trade> {
48 self.trades.iter().filter(|t| !t.is_open())
49 }
50
51 pub fn open_trades(&self) -> impl Iterator<Item = &Trade> {
53 self.trades.iter().filter(|t| t.is_open())
54 }
55
56 pub fn generate_metrics(&self) -> Metrics {
58 let trades = self.win_trades + self.loss_trades + self.even_trades;
59 let final_equity = self.final_equity();
60 let max_drawdown = max_drawdown_percent(&self.equity);
61
62 let bars_per_year = bars_per_year(&self.timeframe);
65 let years = ratio(self.equity.len() as f64, bars_per_year);
66
67 let annual_return = if years > 0.0 && final_equity > 0.0 && self.initial_capital > 0.0 {
70 (final_equity / self.initial_capital).powf(1.0 / years) - 1.0
71 } else {
72 0.0
73 };
74
75 let returns = bar_returns(&self.equity);
76 let (mean, deviation) = mean_and_deviation(&returns);
77 let annualise = bars_per_year.sqrt();
78
79 Metrics {
80 bars: self.equity.len(),
81 initial_capital: self.initial_capital,
82 final_equity,
83 net_profit: self.net_profit,
84 total_return: ratio(final_equity - self.initial_capital, self.initial_capital),
85 annual_return,
86 max_drawdown,
87 sharpe: ratio(mean * annualise, deviation),
88 sortino: ratio(mean * annualise, downside_deviation(&returns)),
89 calmar: ratio(annual_return, max_drawdown),
90 trades,
91 wins: self.win_trades,
92 losses: self.loss_trades,
93 win_rate: ratio(self.win_trades as f64, trades as f64),
94 profit_factor: ratio(self.gross_profit, self.gross_loss),
95 avg_trade: ratio(self.net_profit, trades as f64),
96 exposure: exposure(&self.trades, self.equity.len()),
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
105pub struct Metrics {
106 pub bars: usize,
108 pub initial_capital: f64,
109 pub final_equity: f64,
110 pub net_profit: f64,
111 pub total_return: f64,
113 pub annual_return: f64,
115 pub max_drawdown: f64,
117 pub sharpe: f64,
119 pub sortino: f64,
121 pub calmar: f64,
123 pub trades: usize,
125 pub wins: usize,
126 pub losses: usize,
127 pub win_rate: f64,
128 pub profit_factor: f64,
130 pub avg_trade: f64,
132 pub exposure: f64,
134}
135
136fn ratio(numerator: f64, denominator: f64) -> f64 {
139 if denominator > 0.0 && denominator.is_finite() {
140 numerator / denominator
141 } else {
142 0.0
143 }
144}
145
146fn max_drawdown_percent(equity: &[f64]) -> f64 {
151 let mut peak = f64::NEG_INFINITY;
152 let mut worst = 0.0f64;
153 for &value in equity {
154 peak = peak.max(value);
155 if peak > 0.0 {
156 worst = worst.max((peak - value) / peak);
157 }
158 }
159 worst
160}
161
162fn exposure(trades: &[Trade], bars: usize) -> f64 {
164 if bars == 0 {
165 return 0.0;
166 }
167 let last = bars.saturating_sub(1) as u64;
168 let held: u64 = trades
169 .iter()
170 .map(|t| t.exit_bar.unwrap_or(last).saturating_sub(t.entry_bar))
171 .sum();
172 held as f64 / bars as f64
173}
174
175fn bars_per_year(tf: &Timeframe) -> f64 {
178 match tf.to_millis() {
179 Some(ms) if ms > 0 => MS_PER_YEAR / ms as f64,
180 _ => 0.0,
181 }
182}
183
184fn bar_returns(equity: &[f64]) -> Vec<f64> {
186 equity
187 .windows(2)
188 .filter(|pair| pair[0] > 0.0)
189 .map(|pair| pair[1] / pair[0] - 1.0)
190 .collect()
191}
192
193fn mean_and_deviation(returns: &[f64]) -> (f64, f64) {
195 if returns.is_empty() {
196 return (0.0, 0.0);
197 }
198 let mean = returns.iter().sum::<f64>() / returns.len() as f64;
199 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
200 (mean, variance.sqrt())
201}
202
203fn downside_deviation(returns: &[f64]) -> f64 {
205 if returns.is_empty() {
206 return 0.0;
207 }
208 let sum: f64 = returns
209 .iter()
210 .filter(|r| **r < 0.0)
211 .map(|r| r.powi(2))
212 .sum();
213 (sum / returns.len() as f64).sqrt()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn ratios_over_nothing_are_zero() {
222 assert_eq!(ratio(1.0, 0.0), 0.0);
224 assert_eq!(ratio(0.0, 0.0), 0.0);
225 }
226
227 #[test]
228 fn max_drawdown_percent_is_measured_from_the_peak() {
229 assert_eq!(max_drawdown_percent(&[100.0, 200.0, 100.0, 150.0]), 0.5);
231 assert_eq!(max_drawdown_percent(&[100.0, 110.0, 120.0]), 0.0);
233 }
234
235 #[test]
236 fn generate_metrics_derives_the_summary() {
237 let b = Backtest {
238 initial_capital: 1000.0,
239 equity: vec![1000.0, 1100.0, 1200.0],
240 gross_profit: 200.0,
241 gross_loss: 100.0,
242 net_profit: 100.0,
243 win_trades: 3,
244 loss_trades: 1,
245 even_trades: 0,
246 ..Default::default()
247 };
248 let m = b.generate_metrics();
249
250 assert_eq!(m.bars, 3);
251 assert!((m.total_return - 0.2).abs() < 1e-12); assert_eq!(m.profit_factor, 2.0);
253 assert_eq!(m.win_rate, 0.75); assert_eq!(m.avg_trade, 25.0); assert_eq!(m.trades, 4);
256 }
257
258 #[test]
259 fn annualises_from_the_timeframe() {
260 let equity: Vec<f64> = (0..365)
263 .map(|i| 1000.0 + 1000.0 * i as f64 / 364.0)
264 .collect();
265 let m = Backtest {
266 initial_capital: 1000.0,
267 equity,
268 ..Default::default()
269 }
270 .generate_metrics();
271
272 assert!((m.annual_return - 1.0).abs() < 1e-9);
273 assert!(m.sharpe.is_finite() && m.sharpe > 0.0);
274 }
275}