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 #[serde(default)]
34 pub orders_blocked: usize,
35 pub trades: Vec<TradeOutcome>,
37 pub max_drawdown: f64,
40 pub equity_curve: Vec<f64>,
44 pub period_returns: Vec<f64>,
47 pub risk_free_rate: f64,
50 pub periods_per_year: u32,
53}
54
55impl BacktestResult {
56 pub fn total_return_pct(&self) -> f64 {
58 if self.initial_cash == 0.0 {
59 0.0
60 } else {
61 (self.net_pnl / self.initial_cash) * 100.0
62 }
63 }
64
65 pub fn wins(&self) -> usize {
67 self.trades
68 .iter()
69 .filter(|t| t.outcome() == Outcome::Win)
70 .count()
71 }
72
73 pub fn losses(&self) -> usize {
75 self.trades
76 .iter()
77 .filter(|t| t.outcome() == Outcome::Loss)
78 .count()
79 }
80
81 pub fn breakevens(&self) -> usize {
83 self.trades
84 .iter()
85 .filter(|t| t.outcome() == Outcome::Breakeven)
86 .count()
87 }
88
89 pub fn win_rate(&self) -> f64 {
91 let decided = self.wins() + self.losses();
92 if decided == 0 {
93 0.0
94 } else {
95 self.wins() as f64 / decided as f64
96 }
97 }
98
99 pub fn profit_factor(&self) -> Option<f64> {
102 let wins: f64 = self
103 .trades
104 .iter()
105 .filter(|t| t.outcome() == Outcome::Win)
106 .map(|t| t.net_pnl())
107 .sum();
108 let losses: f64 = self
109 .trades
110 .iter()
111 .filter(|t| t.outcome() == Outcome::Loss)
112 .map(|t| t.net_pnl().abs())
113 .sum();
114 if losses == 0.0 {
115 None
116 } else {
117 Some(wins / losses)
118 }
119 }
120
121 pub fn sharpe_ratio(&self) -> Option<f64> {
133 let r = &self.period_returns;
134 if r.len() < 2 {
135 return None;
136 }
137 let rf = self.risk_free_rate;
138 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
139 let n = excess.len() as f64;
140 let mean = excess.iter().sum::<f64>() / n;
141 let variance = excess.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
142 let stddev = variance.sqrt();
143 if stddev == 0.0 || !stddev.is_finite() {
144 return None;
145 }
146 let scale = (self.periods_per_year as f64).sqrt();
147 Some(scale * mean / stddev)
148 }
149
150 pub fn sortino_ratio(&self) -> Option<f64> {
159 let r = &self.period_returns;
160 if r.len() < 2 {
161 return None;
162 }
163 let rf = self.risk_free_rate;
164 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
165 let n = excess.len() as f64;
166 let mean = excess.iter().sum::<f64>() / n;
167 let downside_var = excess
168 .iter()
169 .map(|x| if *x < 0.0 { x.powi(2) } else { 0.0 })
170 .sum::<f64>()
171 / n;
172 let downside_dev = downside_var.sqrt();
173 if downside_dev == 0.0 || !downside_dev.is_finite() {
174 return None;
175 }
176 let scale = (self.periods_per_year as f64).sqrt();
177 Some(scale * mean / downside_dev)
178 }
179
180 pub fn summary(&self) -> String {
182 let pf = self
183 .profit_factor()
184 .map(|p| format!("{p:.3}"))
185 .unwrap_or_else(|| "∞ (no losses)".into());
186 let sharpe = self
187 .sharpe_ratio()
188 .map(|s| format!("{s:.3}"))
189 .unwrap_or_else(|| "n/a".into());
190 let sortino = self
191 .sortino_ratio()
192 .map(|s| format!("{s:.3}"))
193 .unwrap_or_else(|| "n/a".into());
194 format!(
195 "Backtest [{}]\n\
196 ├ candles_processed: {}\n\
197 ├ signals / orders : {} / {} ({} risk-blocked)\n\
198 ├ trades : {} (W {} / L {} / BE {})\n\
199 ├ win_rate : {:.2}%\n\
200 ├ profit_factor : {pf}\n\
201 ├ sharpe / sortino : {sharpe} / {sortino}\n\
202 ├ total_return : {:.4}%\n\
203 ├ net_pnl : {:.4}\n\
204 ├ total_fees : {:.4}\n\
205 ├ max_drawdown : {:.4}\n\
206 └ final_cash : {:.4}",
207 self.symbol,
208 self.candles_processed,
209 self.signals_emitted,
210 self.orders_filled,
211 self.orders_blocked,
212 self.trades.len(),
213 self.wins(),
214 self.losses(),
215 self.breakevens(),
216 self.win_rate() * 100.0,
217 self.total_return_pct(),
218 self.net_pnl,
219 self.total_fees,
220 self.max_drawdown,
221 self.final_cash,
222 )
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn baseline_result(period_returns: Vec<f64>) -> BacktestResult {
231 let mut equity = vec![10_000.0];
232 let mut prev = 10_000.0;
233 for r in &period_returns {
234 prev *= 1.0 + r;
235 equity.push(prev);
236 }
237 BacktestResult {
238 symbol: "X".into(),
239 initial_cash: 10_000.0,
240 final_cash: prev,
241 net_pnl: prev - 10_000.0,
242 total_fees: 0.0,
243 candles_processed: period_returns.len(),
244 signals_emitted: 0,
245 orders_filled: 0,
246 orders_blocked: 0,
247 trades: Vec::new(),
248 max_drawdown: 0.0,
249 equity_curve: equity,
250 period_returns,
251 risk_free_rate: 0.0,
252 periods_per_year: 252,
253 }
254 }
255
256 #[test]
257 fn sharpe_none_with_one_sample() {
258 let r = baseline_result(vec![0.01]);
259 assert!(r.sharpe_ratio().is_none());
260 }
261
262 #[test]
263 fn sharpe_none_with_zero_variance() {
264 let r = baseline_result(vec![0.0; 20]);
266 assert!(r.sharpe_ratio().is_none());
267 }
268
269 #[test]
270 fn sharpe_positive_on_uptrend_with_some_noise() {
271 let r = baseline_result(vec![
273 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
274 ]);
275 let s = r.sharpe_ratio().unwrap();
276 assert!(s > 0.0, "expected positive sharpe, got {s}");
277 assert!(s.is_finite());
279 }
280
281 #[test]
282 fn sortino_only_penalises_downside() {
283 let r = baseline_result(vec![
286 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
287 ]);
288 let sharpe = r.sharpe_ratio().unwrap();
289 let sortino = r.sortino_ratio().unwrap();
290 assert!(
291 sortino >= sharpe - 1e-9,
292 "sortino={sortino} sharpe={sharpe}"
293 );
294 }
295
296 #[test]
297 fn sortino_none_when_no_downside() {
298 let r = baseline_result(vec![0.01, 0.005, 0.02, 0.001, 0.015]);
299 assert!(r.sortino_ratio().is_none());
300 }
301}