1use 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,
22 pub total_fees: f64,
24 #[serde(default)]
30 pub funding_received: f64,
31 #[serde(default)]
34 pub funding_paid: f64,
35 pub candles_processed: usize,
37 pub signals_emitted: usize,
39 pub orders_filled: usize,
42 #[serde(default)]
48 pub orders_blocked: usize,
49 pub trades: Vec<TradeOutcome>,
51 pub max_drawdown: f64,
54 pub equity_curve: Vec<f64>,
58 #[serde(default)]
65 pub equity_times: Vec<i64>,
66 pub period_returns: Vec<f64>,
69 pub risk_free_rate: f64,
72 pub periods_per_year: u32,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub struct EquityPoint {
82 pub time: i64,
85 pub equity: f64,
88}
89
90impl BacktestResult {
91 pub fn total_return_pct(&self) -> f64 {
93 if self.initial_cash == 0.0 {
94 0.0
95 } else {
96 (self.net_pnl / self.initial_cash) * 100.0
97 }
98 }
99
100 pub fn net_funding(&self) -> f64 {
105 self.funding_received - self.funding_paid
106 }
107
108 pub fn equity_points(&self) -> Vec<EquityPoint> {
114 self.equity_times
115 .iter()
116 .zip(self.equity_curve.iter())
117 .map(|(&time, &equity)| EquityPoint { time, equity })
118 .collect()
119 }
120
121 pub fn wins(&self) -> usize {
123 self.trades
124 .iter()
125 .filter(|t| t.outcome() == Outcome::Win)
126 .count()
127 }
128
129 pub fn losses(&self) -> usize {
131 self.trades
132 .iter()
133 .filter(|t| t.outcome() == Outcome::Loss)
134 .count()
135 }
136
137 pub fn breakevens(&self) -> usize {
139 self.trades
140 .iter()
141 .filter(|t| t.outcome() == Outcome::Breakeven)
142 .count()
143 }
144
145 pub fn win_rate(&self) -> f64 {
147 let decided = self.wins() + self.losses();
148 if decided == 0 {
149 0.0
150 } else {
151 self.wins() as f64 / decided as f64
152 }
153 }
154
155 pub fn expectancy(&self) -> Option<f64> {
159 if self.trades.is_empty() {
160 return None;
161 }
162 let total: f64 = self.trades.iter().map(TradeOutcome::net_pnl).sum();
163 Some(total / self.trades.len() as f64)
164 }
165
166 pub fn avg_win(&self) -> Option<f64> {
169 self.mean_net_where(Outcome::Win)
170 }
171
172 pub fn avg_loss(&self) -> Option<f64> {
177 self.mean_net_where(Outcome::Loss)
178 }
179
180 fn mean_net_where(&self, outcome: Outcome) -> Option<f64> {
181 let mut total = 0.0;
182 let mut n = 0usize;
183 for t in self.trades.iter().filter(|t| t.outcome() == outcome) {
184 total += t.net_pnl();
185 n += 1;
186 }
187 if n == 0 { None } else { Some(total / n as f64) }
188 }
189
190 pub fn profit_factor(&self) -> Option<f64> {
193 let wins: f64 = self
194 .trades
195 .iter()
196 .filter(|t| t.outcome() == Outcome::Win)
197 .map(|t| t.net_pnl())
198 .sum();
199 let losses: f64 = self
200 .trades
201 .iter()
202 .filter(|t| t.outcome() == Outcome::Loss)
203 .map(|t| t.net_pnl().abs())
204 .sum();
205 if losses == 0.0 {
206 None
207 } else {
208 Some(wins / losses)
209 }
210 }
211
212 pub fn sharpe_ratio(&self) -> Option<f64> {
224 let r = &self.period_returns;
225 if r.len() < 2 {
226 return None;
227 }
228 let rf = self.risk_free_rate;
229 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
230 let n = excess.len() as f64;
231 let mean = excess.iter().sum::<f64>() / n;
232 let variance = excess.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
233 let stddev = variance.sqrt();
234 if stddev == 0.0 || !stddev.is_finite() {
235 return None;
236 }
237 let scale = (self.periods_per_year as f64).sqrt();
238 Some(scale * mean / stddev)
239 }
240
241 pub fn sortino_ratio(&self) -> Option<f64> {
250 let r = &self.period_returns;
251 if r.len() < 2 {
252 return None;
253 }
254 let rf = self.risk_free_rate;
255 let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
256 let n = excess.len() as f64;
257 let mean = excess.iter().sum::<f64>() / n;
258 let downside_var = excess
259 .iter()
260 .map(|x| if *x < 0.0 { x.powi(2) } else { 0.0 })
261 .sum::<f64>()
262 / n;
263 let downside_dev = downside_var.sqrt();
264 if downside_dev == 0.0 || !downside_dev.is_finite() {
265 return None;
266 }
267 let scale = (self.periods_per_year as f64).sqrt();
268 Some(scale * mean / downside_dev)
269 }
270
271 pub fn summary(&self) -> String {
273 let pf = self
274 .profit_factor()
275 .map(|p| format!("{p:.3}"))
276 .unwrap_or_else(|| "∞ (no losses)".into());
277 let sharpe = self
278 .sharpe_ratio()
279 .map(|s| format!("{s:.3}"))
280 .unwrap_or_else(|| "n/a".into());
281 let sortino = self
282 .sortino_ratio()
283 .map(|s| format!("{s:.3}"))
284 .unwrap_or_else(|| "n/a".into());
285 let fmt_opt = |v: Option<f64>| v.map(|x| format!("{x:.4}")).unwrap_or_else(|| "n/a".into());
286 let expectancy = fmt_opt(self.expectancy());
287 let avg_win = fmt_opt(self.avg_win());
288 let avg_loss = fmt_opt(self.avg_loss());
289 format!(
290 "Backtest [{}]\n\
291 ├ candles_processed: {}\n\
292 ├ signals / orders : {} / {} ({} risk-blocked)\n\
293 ├ trades : {} (W {} / L {} / BE {})\n\
294 ├ win_rate : {:.2}%\n\
295 ├ expectancy : {expectancy}\n\
296 ├ avg win / loss : {avg_win} / {avg_loss}\n\
297 ├ profit_factor : {pf}\n\
298 ├ sharpe / sortino : {sharpe} / {sortino}\n\
299 ├ total_return : {:.4}%\n\
300 ├ net_pnl : {:.4}\n\
301 ├ total_fees : {:.4}\n\
302 ├ funding recv/paid: {:.4} / {:.4} (net {:.4})\n\
303 ├ max_drawdown : {:.4}\n\
304 └ final_cash : {:.4}",
305 self.symbol,
306 self.candles_processed,
307 self.signals_emitted,
308 self.orders_filled,
309 self.orders_blocked,
310 self.trades.len(),
311 self.wins(),
312 self.losses(),
313 self.breakevens(),
314 self.win_rate() * 100.0,
315 self.total_return_pct(),
316 self.net_pnl,
317 self.total_fees,
318 self.funding_received,
319 self.funding_paid,
320 self.net_funding(),
321 self.max_drawdown,
322 self.final_cash,
323 )
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn baseline_result(period_returns: Vec<f64>) -> BacktestResult {
332 let mut equity = vec![10_000.0];
333 let mut prev = 10_000.0;
334 for r in &period_returns {
335 prev *= 1.0 + r;
336 equity.push(prev);
337 }
338 BacktestResult {
339 symbol: "X".into(),
340 initial_cash: 10_000.0,
341 final_cash: prev,
342 net_pnl: prev - 10_000.0,
343 total_fees: 0.0,
344 funding_received: 0.0,
345 funding_paid: 0.0,
346 candles_processed: period_returns.len(),
347 signals_emitted: 0,
348 orders_filled: 0,
349 orders_blocked: 0,
350 trades: Vec::new(),
351 max_drawdown: 0.0,
352 equity_curve: equity,
353 equity_times: Vec::new(),
354 period_returns,
355 risk_free_rate: 0.0,
356 periods_per_year: 252,
357 }
358 }
359
360 #[test]
361 fn sharpe_none_with_one_sample() {
362 let r = baseline_result(vec![0.01]);
363 assert!(r.sharpe_ratio().is_none());
364 }
365
366 #[test]
367 fn sharpe_none_with_zero_variance() {
368 let r = baseline_result(vec![0.0; 20]);
370 assert!(r.sharpe_ratio().is_none());
371 }
372
373 #[test]
374 fn sharpe_positive_on_uptrend_with_some_noise() {
375 let r = baseline_result(vec![
377 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
378 ]);
379 let s = r.sharpe_ratio().unwrap();
380 assert!(s > 0.0, "expected positive sharpe, got {s}");
381 assert!(s.is_finite());
383 }
384
385 #[test]
386 fn sortino_only_penalises_downside() {
387 let r = baseline_result(vec![
390 0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
391 ]);
392 let sharpe = r.sharpe_ratio().unwrap();
393 let sortino = r.sortino_ratio().unwrap();
394 assert!(
395 sortino >= sharpe - 1e-9,
396 "sortino={sortino} sharpe={sharpe}"
397 );
398 }
399
400 #[test]
401 fn sortino_none_when_no_downside() {
402 let r = baseline_result(vec![0.01, 0.005, 0.02, 0.001, 0.015]);
403 assert!(r.sortino_ratio().is_none());
404 }
405
406 fn trade(net: f64) -> TradeOutcome {
411 TradeOutcome {
412 symbol: "X".into(),
413 close_side: rustrade_core::Side::Sell,
414 qty: 1.0,
415 entry_price: 100.0,
416 exit_price: 100.0 + net,
417 gross_pnl: net,
418 fee: 0.0,
419 funding: 0.0,
420 closed_at: chrono::Utc::now(),
421 }
422 }
423
424 #[test]
425 fn trade_stats_on_known_trade_set() {
426 let mut r = baseline_result(vec![0.0; 4]);
427 r.trades = vec![trade(10.0), trade(20.0), trade(-5.0), trade(0.0)];
428
429 assert_eq!(r.wins(), 2);
430 assert_eq!(r.losses(), 1);
431 assert_eq!(r.breakevens(), 1);
432 assert!((r.avg_win().unwrap() - 15.0).abs() < 1e-12);
433 assert!((r.avg_loss().unwrap() - (-5.0)).abs() < 1e-12);
434 assert!((r.expectancy().unwrap() - 6.25).abs() < 1e-12);
436 let n = r.trades.len() as f64;
438 let lhs = n * r.expectancy().unwrap();
439 let rhs =
440 r.wins() as f64 * r.avg_win().unwrap() + r.losses() as f64 * r.avg_loss().unwrap();
441 assert!((lhs - rhs).abs() < 1e-12);
442 }
443
444 #[test]
445 fn trade_stats_none_without_matching_trades() {
446 let r = baseline_result(vec![0.0; 2]);
447 assert!(r.expectancy().is_none());
448 assert!(r.avg_win().is_none());
449 assert!(r.avg_loss().is_none());
450
451 let mut winners_only = baseline_result(vec![0.0; 2]);
452 winners_only.trades = vec![trade(3.0)];
453 assert!(winners_only.avg_win().is_some());
454 assert!(winners_only.avg_loss().is_none());
455 assert!((winners_only.expectancy().unwrap() - 3.0).abs() < 1e-12);
456 }
457
458 #[test]
459 fn trade_net_includes_funding() {
460 let mut t = trade(5.0);
463 t.fee = 1.0;
464 t.funding = 2.0;
465 assert!((t.net_pnl() - 6.0).abs() < 1e-12);
466
467 let mut r = baseline_result(vec![0.0; 1]);
468 r.trades = vec![t];
469 assert_eq!(r.wins(), 1);
470 assert!((r.expectancy().unwrap() - 6.0).abs() < 1e-12);
471 }
472
473 #[test]
476 fn equity_points_zip_times_with_curve() {
477 let mut r = baseline_result(vec![0.01, -0.01]);
478 r.equity_times = vec![1_000, 1_000, 2_000];
480 let points = r.equity_points();
481 assert_eq!(points.len(), 3);
482 assert_eq!(points[0].time, 1_000);
483 assert_eq!(points[2].time, 2_000);
484 assert_eq!(points[1].equity, r.equity_curve[1]);
485 }
486
487 #[test]
488 fn equity_points_empty_without_timestamps() {
489 let r = baseline_result(vec![0.01, -0.01]);
492 assert!(r.equity_times.is_empty());
493 assert!(r.equity_points().is_empty());
494 }
495
496 #[test]
497 fn net_funding_is_received_minus_paid() {
498 let mut r = baseline_result(vec![0.0]);
499 r.funding_received = 3.5;
500 r.funding_paid = 1.25;
501 assert!((r.net_funding() - 2.25).abs() < 1e-12);
502 }
503}