rustrade_backtest/
result.rs1use serde::{Deserialize, Serialize};
4
5use crate::metrics::{Outcome, TradeOutcome};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct BacktestResult {
10 pub symbol: String,
13 pub initial_cash: f64,
15 pub final_cash: f64,
17 pub net_pnl: f64,
19 pub total_fees: f64,
21 pub candles_processed: usize,
23 pub signals_emitted: usize,
25 pub orders_filled: usize,
28 pub trades: Vec<TradeOutcome>,
30 pub max_drawdown: f64,
33 pub equity_curve: Vec<f64>,
37 pub period_returns: Vec<f64>,
40 pub risk_free_rate: f64,
43 pub periods_per_year: u32,
46}
47
48impl BacktestResult {
49 pub fn total_return_pct(&self) -> f64 {
51 if self.initial_cash == 0.0 {
52 0.0
53 } else {
54 (self.net_pnl / self.initial_cash) * 100.0
55 }
56 }
57
58 pub fn wins(&self) -> usize {
60 self.trades
61 .iter()
62 .filter(|t| t.outcome() == Outcome::Win)
63 .count()
64 }
65
66 pub fn losses(&self) -> usize {
68 self.trades
69 .iter()
70 .filter(|t| t.outcome() == Outcome::Loss)
71 .count()
72 }
73
74 pub fn breakevens(&self) -> usize {
76 self.trades
77 .iter()
78 .filter(|t| t.outcome() == Outcome::Breakeven)
79 .count()
80 }
81
82 pub fn win_rate(&self) -> f64 {
84 let decided = self.wins() + self.losses();
85 if decided == 0 {
86 0.0
87 } else {
88 self.wins() as f64 / decided as f64
89 }
90 }
91
92 pub fn profit_factor(&self) -> Option<f64> {
95 let wins: f64 = self
96 .trades
97 .iter()
98 .filter(|t| t.outcome() == Outcome::Win)
99 .map(|t| t.net_pnl())
100 .sum();
101 let losses: f64 = self
102 .trades
103 .iter()
104 .filter(|t| t.outcome() == Outcome::Loss)
105 .map(|t| t.net_pnl().abs())
106 .sum();
107 if losses == 0.0 {
108 None
109 } else {
110 Some(wins / losses)
111 }
112 }
113
114 pub fn sharpe_ratio(&self) -> Option<f64> {
126 let r = &self.period_returns;
127 if r.len() < 2 {
128 return None;
129 }
130 let rf = self.risk_free_rate;
131 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
132 let n = excess.len() as f64;
133 let mean = excess.iter().sum::<f64>() / n;
134 let variance = excess.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
135 let stddev = variance.sqrt();
136 if stddev == 0.0 || !stddev.is_finite() {
137 return None;
138 }
139 let scale = (self.periods_per_year as f64).sqrt();
140 Some(scale * mean / stddev)
141 }
142
143 pub fn sortino_ratio(&self) -> Option<f64> {
152 let r = &self.period_returns;
153 if r.len() < 2 {
154 return None;
155 }
156 let rf = self.risk_free_rate;
157 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
158 let n = excess.len() as f64;
159 let mean = excess.iter().sum::<f64>() / n;
160 let downside_var = excess
161 .iter()
162 .map(|x| if *x < 0.0 { x.powi(2) } else { 0.0 })
163 .sum::<f64>()
164 / n;
165 let downside_dev = downside_var.sqrt();
166 if downside_dev == 0.0 || !downside_dev.is_finite() {
167 return None;
168 }
169 let scale = (self.periods_per_year as f64).sqrt();
170 Some(scale * mean / downside_dev)
171 }
172
173 pub fn summary(&self) -> String {
175 let pf = self
176 .profit_factor()
177 .map(|p| format!("{p:.3}"))
178 .unwrap_or_else(|| "∞ (no losses)".into());
179 let sharpe = self
180 .sharpe_ratio()
181 .map(|s| format!("{s:.3}"))
182 .unwrap_or_else(|| "n/a".into());
183 let sortino = self
184 .sortino_ratio()
185 .map(|s| format!("{s:.3}"))
186 .unwrap_or_else(|| "n/a".into());
187 format!(
188 "Backtest [{}]\n\
189 ├ candles_processed: {}\n\
190 ├ signals / orders : {} / {}\n\
191 ├ trades : {} (W {} / L {} / BE {})\n\
192 ├ win_rate : {:.2}%\n\
193 ├ profit_factor : {pf}\n\
194 ├ sharpe / sortino : {sharpe} / {sortino}\n\
195 ├ total_return : {:.4}%\n\
196 ├ net_pnl : {:.4}\n\
197 ├ total_fees : {:.4}\n\
198 ├ max_drawdown : {:.4}\n\
199 └ final_cash : {:.4}",
200 self.symbol,
201 self.candles_processed,
202 self.signals_emitted,
203 self.orders_filled,
204 self.trades.len(),
205 self.wins(),
206 self.losses(),
207 self.breakevens(),
208 self.win_rate() * 100.0,
209 self.total_return_pct(),
210 self.net_pnl,
211 self.total_fees,
212 self.max_drawdown,
213 self.final_cash,
214 )
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn baseline_result(period_returns: Vec<f64>) -> BacktestResult {
223 let mut equity = vec![10_000.0];
224 let mut prev = 10_000.0;
225 for r in &period_returns {
226 prev *= 1.0 + r;
227 equity.push(prev);
228 }
229 BacktestResult {
230 symbol: "X".into(),
231 initial_cash: 10_000.0,
232 final_cash: prev,
233 net_pnl: prev - 10_000.0,
234 total_fees: 0.0,
235 candles_processed: period_returns.len(),
236 signals_emitted: 0,
237 orders_filled: 0,
238 trades: Vec::new(),
239 max_drawdown: 0.0,
240 equity_curve: equity,
241 period_returns,
242 risk_free_rate: 0.0,
243 periods_per_year: 252,
244 }
245 }
246
247 #[test]
248 fn sharpe_none_with_one_sample() {
249 let r = baseline_result(vec![0.01]);
250 assert!(r.sharpe_ratio().is_none());
251 }
252
253 #[test]
254 fn sharpe_none_with_zero_variance() {
255 let r = baseline_result(vec![0.0; 20]);
257 assert!(r.sharpe_ratio().is_none());
258 }
259
260 #[test]
261 fn sharpe_positive_on_uptrend_with_some_noise() {
262 let r = baseline_result(vec![
264 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
265 ]);
266 let s = r.sharpe_ratio().unwrap();
267 assert!(s > 0.0, "expected positive sharpe, got {s}");
268 assert!(s.is_finite());
270 }
271
272 #[test]
273 fn sortino_only_penalises_downside() {
274 let r = baseline_result(vec![
277 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
278 ]);
279 let sharpe = r.sharpe_ratio().unwrap();
280 let sortino = r.sortino_ratio().unwrap();
281 assert!(
282 sortino >= sharpe - 1e-9,
283 "sortino={sortino} sharpe={sharpe}"
284 );
285 }
286
287 #[test]
288 fn sortino_none_when_no_downside() {
289 let r = baseline_result(vec![0.01, 0.005, 0.02, 0.001, 0.015]);
290 assert!(r.sortino_ratio().is_none());
291 }
292}