1use crate::data_source::DataSource;
2use crate::engine::Engine;
3use crate::matcher::Matcher;
4use crate::types::{DEFAULT_AGENT, Side};
5
6#[derive(Debug, Clone)]
7pub struct BacktestResult {
8 pub total_return: f64,
9 pub annualized_return: f64,
10 pub volatility: f64,
11 pub sharpe_ratio: f64,
12 pub sortino_ratio: f64,
13 pub max_drawdown: f64,
14 pub total_trades: usize,
15 pub final_equity: f64,
16 pub mean_inventory: f64,
17 pub max_inventory: f64,
18 pub min_inventory: f64,
19 pub adverse_selection_bps: f64,
20 pub realized_edge_bps: f64,
21 pub volatility_path: Option<Vec<f64>>,
22 pub inventory_variance: f64,
24 pub pnl_spread: f64,
26 pub pnl_dir: f64,
28 pub fill_buy_count: usize,
30 pub fill_sell_count: usize,
32 pub mean_hold_time: f64,
34 pub terminal_liquidation_cost: f64,
36}
37
38impl std::fmt::Display for BacktestResult {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 writeln!(f, "--- Backtest Results ---")?;
41 writeln!(f, "Total Return: {:.2}%", self.total_return * 100.0)?;
42 writeln!(
43 f,
44 "Annualized Return: {:.2}%",
45 self.annualized_return * 100.0
46 )?;
47 writeln!(f, "Volatility: {:.2}%", self.volatility * 100.0)?;
48 writeln!(f, "Sharpe Ratio: {:.2}", self.sharpe_ratio)?;
49 writeln!(f, "Sortino Ratio: {:.2}", self.sortino_ratio)?;
50 writeln!(f, "Max Drawdown: {:.2}%", self.max_drawdown * 100.0)?;
51 writeln!(f, "Total Trades: {}", self.total_trades)?;
52 writeln!(f, "Final Equity: {:.2}", self.final_equity)?;
53 writeln!(f, "Mean Inventory: {:.2}", self.mean_inventory)?;
54 writeln!(f, "Max Inventory: {:.2}", self.max_inventory)?;
55 writeln!(f, "Min Inventory: {:.2}", self.min_inventory)?;
56 writeln!(
57 f,
58 "Adverse Selection: {:.2} bps",
59 self.adverse_selection_bps
60 )?;
61 writeln!(f, "Realized Edge: {:.2} bps", self.realized_edge_bps)?;
62 writeln!(f, "Inventory Variance: {:.4}", self.inventory_variance)?;
63 writeln!(f, "PnL Spread: {:.4}", self.pnl_spread)?;
64 writeln!(f, "PnL Directional: {:.4}", self.pnl_dir)?;
65 writeln!(f, "Fill Buy Count: {}", self.fill_buy_count)?;
66 writeln!(f, "Fill Sell Count: {}", self.fill_sell_count)?;
67 writeln!(f, "Mean Hold Time: {:.2} steps", self.mean_hold_time)?;
68 writeln!(
69 f,
70 "Terminal Liq. Cost: {:.4}",
71 self.terminal_liquidation_cost
72 )?;
73 Ok(())
74 }
75}
76
77const DEFAULT_LOOKBACK: usize = 10;
79
80pub fn run_backtest<M, D>(engine: &mut Engine<M, D>, dt_years: f64) -> BacktestResult
82where
83 M: Matcher,
84 D: DataSource,
85{
86 run_backtest_impl(engine, dt_years, DEFAULT_LOOKBACK, None)
87}
88
89pub fn run_backtest_with_liquidation<M, D>(
95 engine: &mut Engine<M, D>,
96 dt_years: f64,
97 liquidation_half_spread: f64,
98) -> BacktestResult
99where
100 M: Matcher,
101 D: DataSource,
102{
103 run_backtest_impl(
104 engine,
105 dt_years,
106 DEFAULT_LOOKBACK,
107 Some(liquidation_half_spread),
108 )
109}
110
111pub fn run_backtest_lookback<M, D>(
113 engine: &mut Engine<M, D>,
114 dt_years: f64,
115 lookback: usize,
116) -> BacktestResult
117where
118 M: Matcher,
119 D: DataSource,
120{
121 run_backtest_impl(engine, dt_years, lookback, None)
122}
123
124fn run_backtest_impl<M, D>(
125 engine: &mut Engine<M, D>,
126 dt_years: f64,
127 lookback: usize,
128 liquidation_half_spread: Option<f64>,
129) -> BacktestResult
130where
131 M: Matcher,
132 D: DataSource,
133{
134 let initial_cash = engine.get_portfolio().cash;
135 let mut max_equity = initial_cash;
136 let mut max_drawdown = 0.0;
137
138 let mut prev_equity = initial_cash;
139
140 let mut sum_returns = 0.0;
142 let mut sum_sq_returns = 0.0;
143 let mut sum_downside_sq = 0.0;
144 let mut count_returns = 0.0;
145
146 let mut sum_inventory = 0.0;
147 let mut sum_inventory_sq = 0.0;
148 let mut max_inventory = 0.0;
149 let mut min_inventory = 0.0;
150 let mut count_steps = 0.0;
151
152 let mut pnl_dir_val = 0.0;
154 let mut prev_pos = 0.0_f64;
155 let mut prev_price = 0.0_f64;
156
157 let mut hold_duration: usize = 0;
159 let mut hold_times: Vec<usize> = Vec::new();
160
161 let mut volatility_path = Vec::with_capacity(5000);
162
163 let mut mid_prices: Vec<f64> = Vec::with_capacity(5000);
165 let mut fill_records: Vec<(usize, Side, f64, f64)> = Vec::new();
167 let mut step_idx: usize = 0;
168
169 while engine.step() {
170 let portfolio = engine.get_portfolio();
171 let last_state = engine.get_last_state();
172
173 let price = if let Some(state) = last_state {
174 if let Some(vol) = state.true_volatility {
175 volatility_path.push(vol);
176 }
177 (state.best_bid + state.best_ask) / 2.0
178 } else {
179 0.0
180 };
181
182 mid_prices.push(price);
183
184 for (agent_id, fill) in engine.last_step_fills() {
186 if *agent_id == DEFAULT_AGENT {
187 fill_records.push((step_idx, fill.side, fill.price, price));
188 }
189 }
190
191 let equity = portfolio.cash + portfolio.position * price;
193
194 if equity > max_equity {
196 max_equity = equity;
197 } else {
198 let drawdown = (max_equity - equity) / max_equity;
199 if drawdown > max_drawdown {
200 max_drawdown = drawdown;
201 }
202 }
203
204 let pos = portfolio.position;
206 sum_inventory += pos;
207 sum_inventory_sq += pos * pos;
208 if pos > max_inventory {
209 max_inventory = pos;
210 }
211 if pos < min_inventory {
212 min_inventory = pos;
213 }
214 count_steps += 1.0;
215
216 let d_price = price - prev_price;
219 pnl_dir_val += prev_pos * d_price;
220
221 if pos.abs() < 1e-9 {
223 if hold_duration > 0 {
224 hold_times.push(hold_duration);
225 hold_duration = 0;
226 }
227 } else {
228 hold_duration += 1;
229 }
230
231 if prev_equity.abs() > 1e-9 {
233 let r = (equity - prev_equity) / prev_equity;
234 sum_returns += r;
235 sum_sq_returns += r * r;
236 if r < 0.0 {
238 sum_downside_sq += r * r;
239 }
240 count_returns += 1.0;
241 }
242
243 prev_equity = equity;
244 prev_pos = pos;
245 prev_price = price;
246 step_idx += 1;
247 }
248
249 if hold_duration > 0 {
251 hold_times.push(hold_duration);
252 }
253 let mean_hold_time = if hold_times.is_empty() {
254 0.0
255 } else {
256 hold_times.iter().sum::<usize>() as f64 / hold_times.len() as f64
257 };
258
259 let terminal_liquidation_cost = if let Some(hs) = liquidation_half_spread {
263 prev_pos.abs() * hs
264 } else {
265 0.0
266 };
267 let final_equity = prev_equity - terminal_liquidation_cost;
268 let total_return = (final_equity - initial_cash) / initial_cash;
269
270 let mean_inventory = if count_steps > 0.0 {
271 sum_inventory / count_steps
272 } else {
273 0.0
274 };
275 let inventory_variance = if count_steps > 0.0 {
276 (sum_inventory_sq / count_steps - mean_inventory * mean_inventory).max(0.0)
277 } else {
278 0.0
279 };
280
281 let (mean_return, std_dev) = if count_returns > 1.0 {
282 let mean = sum_returns / count_returns;
283 let variance =
284 (sum_sq_returns - (sum_returns * sum_returns) / count_returns) / (count_returns - 1.0);
285 let variance = variance.max(0.0);
286 (mean, variance.sqrt())
287 } else {
288 (0.0, 0.0)
289 };
290
291 let downside_deviation = if count_returns > 1.0 {
292 (sum_downside_sq / count_returns).sqrt()
293 } else {
294 0.0
295 };
296
297 let steps_per_year = 1.0 / dt_years;
298 let annualized_return = mean_return * steps_per_year;
299 let annualized_volatility = std_dev * steps_per_year.sqrt();
300 let annualized_downside = downside_deviation * steps_per_year.sqrt();
301
302 let sharpe_ratio = if annualized_volatility > 0.0 {
303 annualized_return / annualized_volatility
304 } else {
305 0.0
306 };
307
308 let sortino_ratio = if annualized_downside > 0.0 {
309 annualized_return / annualized_downside
310 } else {
311 0.0
312 };
313
314 let mut total_as = 0.0;
316 let mut as_count = 0usize;
317 let mut total_edge = 0.0;
318 let mut edge_count = 0usize;
319 let mut pnl_spread_val = 0.0;
320 let mut fill_buy_count = 0usize;
321 let mut fill_sell_count = 0usize;
322
323 for &(step, side, fill_price, mid_at_fill) in &fill_records {
324 if !mid_prices.is_empty() {
326 let future_step = (step + lookback).min(mid_prices.len() - 1);
327 let future_mid = mid_prices[future_step];
328 let as_value = match side {
329 Side::Buy => future_mid - mid_at_fill,
330 Side::Sell => mid_at_fill - future_mid,
331 };
332 if mid_at_fill.abs() > 1e-9 {
333 total_as += as_value / mid_at_fill * 10000.0;
334 as_count += 1;
335 }
336 }
337 let edge = match side {
339 Side::Buy => mid_at_fill - fill_price,
340 Side::Sell => fill_price - mid_at_fill,
341 };
342 if mid_at_fill.abs() > 1e-9 {
343 total_edge += edge / mid_at_fill * 10000.0;
344 edge_count += 1;
345 }
346 pnl_spread_val += edge;
352 match side {
353 Side::Buy => fill_buy_count += 1,
354 Side::Sell => fill_sell_count += 1,
355 }
356 }
357
358 let adverse_selection_bps = if as_count > 0 {
359 total_as / as_count as f64
360 } else {
361 0.0
362 };
363 let realized_edge_bps = if edge_count > 0 {
364 total_edge / edge_count as f64
365 } else {
366 0.0
367 };
368
369 pnl_spread_val -= terminal_liquidation_cost;
372
373 BacktestResult {
374 total_return,
375 annualized_return,
376 volatility: annualized_volatility,
377 sharpe_ratio,
378 sortino_ratio,
379 max_drawdown,
380 total_trades: engine.total_fills,
381 final_equity,
382 inventory_variance,
383 pnl_spread: pnl_spread_val,
384 pnl_dir: pnl_dir_val,
385 fill_buy_count,
386 fill_sell_count,
387 mean_hold_time,
388 terminal_liquidation_cost,
389 mean_inventory,
390 max_inventory,
391 min_inventory,
392 adverse_selection_bps,
393 realized_edge_bps,
394 volatility_path: if volatility_path.is_empty() {
395 None
396 } else {
397 Some(volatility_path)
398 },
399 }
400}
401
402pub struct BacktestSummary {
403 pub mean_total_return: f64,
404 pub std_dev_total_return: f64,
405 pub mean_sharpe_ratio: f64,
406 pub std_dev_sharpe_ratio: f64,
407 pub mean_sortino_ratio: f64,
408 pub mean_max_drawdown: f64,
409 pub mean_inventory: f64,
410 pub mean_max_inventory: f64,
411 pub mean_min_inventory: f64,
412 pub mean_total_trades: f64,
413 pub abs_max_inventory: f64,
414 pub abs_min_inventory: f64,
415 pub mean_adverse_selection_bps: f64,
416 pub mean_realized_edge_bps: f64,
417 pub total_trajectories: usize,
418 pub mean_inventory_variance: f64,
420 pub mean_pnl_spread: f64,
422 pub mean_pnl_dir: f64,
424 pub mean_fill_asymmetry: f64,
426 pub mean_hold_time: f64,
428 pub std_sortino_ratio: f64,
430 pub std_max_drawdown: f64,
431 pub std_inventory_variance: f64,
432 pub std_pnl_spread: f64,
433 pub std_pnl_dir: f64,
434 pub std_fill_asymmetry: f64,
435 pub std_hold_time: f64,
436 pub std_adverse_selection_bps: f64,
437 pub std_realized_edge_bps: f64,
438 pub std_total_trades: f64,
439 pub mean_abs_peak_inventory: f64,
441 pub std_abs_peak_inventory: f64,
443}
444
445impl BacktestSummary {
446 pub fn new(results: &[BacktestResult]) -> Self {
447 let n = results.len() as f64;
448 if n == 0.0 {
449 return Self {
450 mean_total_return: 0.0,
451 std_dev_total_return: 0.0,
452 mean_sharpe_ratio: 0.0,
453 std_dev_sharpe_ratio: 0.0,
454 mean_sortino_ratio: 0.0,
455 mean_max_drawdown: 0.0,
456 mean_inventory: 0.0,
457 mean_max_inventory: 0.0,
458 mean_min_inventory: 0.0,
459 mean_total_trades: 0.0,
460 abs_max_inventory: 0.0,
461 abs_min_inventory: 0.0,
462 mean_adverse_selection_bps: 0.0,
463 mean_realized_edge_bps: 0.0,
464 total_trajectories: 0,
465 mean_inventory_variance: 0.0,
466 mean_pnl_spread: 0.0,
467 mean_pnl_dir: 0.0,
468 mean_fill_asymmetry: 0.0,
469 mean_hold_time: 0.0,
470 std_sortino_ratio: 0.0,
471 std_max_drawdown: 0.0,
472 std_inventory_variance: 0.0,
473 std_pnl_spread: 0.0,
474 std_pnl_dir: 0.0,
475 std_fill_asymmetry: 0.0,
476 std_hold_time: 0.0,
477 std_adverse_selection_bps: 0.0,
478 std_realized_edge_bps: 0.0,
479 std_total_trades: 0.0,
480 mean_abs_peak_inventory: 0.0,
481 std_abs_peak_inventory: 0.0,
482 };
483 }
484
485 let mean_total_return = results.iter().map(|r| r.total_return).sum::<f64>() / n;
486 let var_total_return = results
487 .iter()
488 .map(|r| (r.total_return - mean_total_return).powi(2))
489 .sum::<f64>()
490 / n;
491 let std_dev_total_return = var_total_return.sqrt();
492
493 let mean_sharpe_ratio = results.iter().map(|r| r.sharpe_ratio).sum::<f64>() / n;
494 let var_sharpe_ratio = results
495 .iter()
496 .map(|r| (r.sharpe_ratio - mean_sharpe_ratio).powi(2))
497 .sum::<f64>()
498 / n;
499 let std_dev_sharpe_ratio = var_sharpe_ratio.sqrt();
500
501 let mean_sortino_ratio = results.iter().map(|r| r.sortino_ratio).sum::<f64>() / n;
502 let mean_max_drawdown = results.iter().map(|r| r.max_drawdown).sum::<f64>() / n;
503
504 let std_sortino_ratio = {
505 let m = mean_sortino_ratio;
506 (results
507 .iter()
508 .map(|r| (r.sortino_ratio - m).powi(2))
509 .sum::<f64>()
510 / n)
511 .sqrt()
512 };
513 let std_max_drawdown = {
514 let m = mean_max_drawdown;
515 (results
516 .iter()
517 .map(|r| (r.max_drawdown - m).powi(2))
518 .sum::<f64>()
519 / n)
520 .sqrt()
521 };
522
523 let mean_inventory = results.iter().map(|r| r.mean_inventory).sum::<f64>() / n;
524 let mean_max_inventory = results.iter().map(|r| r.max_inventory).sum::<f64>() / n;
525 let mean_min_inventory = results.iter().map(|r| r.min_inventory).sum::<f64>() / n;
526 let mean_total_trades = results.iter().map(|r| r.total_trades as f64).sum::<f64>() / n;
527 let std_total_trades = {
528 let m = mean_total_trades;
529 (results
530 .iter()
531 .map(|r| (r.total_trades as f64 - m).powi(2))
532 .sum::<f64>()
533 / n)
534 .sqrt()
535 };
536
537 let abs_max_inventory = results
538 .iter()
539 .fold(f64::NEG_INFINITY, |a, r| a.max(r.max_inventory));
540 let abs_min_inventory = results
541 .iter()
542 .fold(f64::INFINITY, |a, r| a.min(r.min_inventory));
543
544 let abs_peak_per_path: Vec<f64> = results
546 .iter()
547 .map(|r| r.max_inventory.abs().max(r.min_inventory.abs()))
548 .collect();
549 let mean_abs_peak_inventory = abs_peak_per_path.iter().sum::<f64>() / n;
550 let std_abs_peak_inventory = {
551 let m = mean_abs_peak_inventory;
552 (abs_peak_per_path
553 .iter()
554 .map(|x| (x - m).powi(2))
555 .sum::<f64>()
556 / n)
557 .sqrt()
558 };
559
560 let mean_adverse_selection_bps =
561 results.iter().map(|r| r.adverse_selection_bps).sum::<f64>() / n;
562 let mean_realized_edge_bps = results.iter().map(|r| r.realized_edge_bps).sum::<f64>() / n;
563 let std_adverse_selection_bps = {
564 let m = mean_adverse_selection_bps;
565 (results
566 .iter()
567 .map(|r| (r.adverse_selection_bps - m).powi(2))
568 .sum::<f64>()
569 / n)
570 .sqrt()
571 };
572 let std_realized_edge_bps = {
573 let m = mean_realized_edge_bps;
574 (results
575 .iter()
576 .map(|r| (r.realized_edge_bps - m).powi(2))
577 .sum::<f64>()
578 / n)
579 .sqrt()
580 };
581
582 let mean_inventory_variance = results.iter().map(|r| r.inventory_variance).sum::<f64>() / n;
583 let std_inventory_variance = {
584 let m = mean_inventory_variance;
585 (results
586 .iter()
587 .map(|r| (r.inventory_variance - m).powi(2))
588 .sum::<f64>()
589 / n)
590 .sqrt()
591 };
592
593 let mean_pnl_spread = results.iter().map(|r| r.pnl_spread).sum::<f64>() / n;
594 let std_pnl_spread = {
595 let m = mean_pnl_spread;
596 (results
597 .iter()
598 .map(|r| (r.pnl_spread - m).powi(2))
599 .sum::<f64>()
600 / n)
601 .sqrt()
602 };
603
604 let mean_pnl_dir = results.iter().map(|r| r.pnl_dir).sum::<f64>() / n;
605 let std_pnl_dir = {
606 let m = mean_pnl_dir;
607 (results.iter().map(|r| (r.pnl_dir - m).powi(2)).sum::<f64>() / n).sqrt()
608 };
609
610 let mean_hold_time = results.iter().map(|r| r.mean_hold_time).sum::<f64>() / n;
611 let std_hold_time = {
612 let m = mean_hold_time;
613 (results
614 .iter()
615 .map(|r| (r.mean_hold_time - m).powi(2))
616 .sum::<f64>()
617 / n)
618 .sqrt()
619 };
620
621 let asym_vals: Vec<f64> = results
623 .iter()
624 .map(|r| {
625 if r.fill_sell_count > 0 {
626 r.fill_buy_count as f64 / r.fill_sell_count as f64
627 } else if r.fill_buy_count > 0 {
628 f64::INFINITY
629 } else {
630 1.0
631 }
632 })
633 .filter(|v| v.is_finite())
634 .collect();
635 let n_asym = asym_vals.len() as f64;
636 let mean_fill_asymmetry = if n_asym > 0.0 {
637 asym_vals.iter().sum::<f64>() / n_asym
638 } else {
639 1.0
640 };
641 let std_fill_asymmetry = if n_asym > 1.0 {
642 let m = mean_fill_asymmetry;
643 (asym_vals.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n_asym).sqrt()
644 } else {
645 0.0
646 };
647
648 Self {
649 mean_total_return,
650 std_dev_total_return,
651 mean_sharpe_ratio,
652 std_dev_sharpe_ratio,
653 mean_sortino_ratio,
654 mean_max_drawdown,
655 mean_inventory,
656 mean_max_inventory,
657 mean_min_inventory,
658 mean_total_trades,
659 abs_max_inventory,
660 abs_min_inventory,
661 mean_adverse_selection_bps,
662 mean_realized_edge_bps,
663 total_trajectories: results.len(),
664 mean_inventory_variance,
665 mean_pnl_spread,
666 mean_pnl_dir,
667 mean_fill_asymmetry,
668 mean_hold_time,
669 std_sortino_ratio,
670 std_max_drawdown,
671 std_inventory_variance,
672 std_pnl_spread,
673 std_pnl_dir,
674 std_fill_asymmetry,
675 std_hold_time,
676 std_adverse_selection_bps,
677 std_realized_edge_bps,
678 std_total_trades,
679 mean_abs_peak_inventory,
680 std_abs_peak_inventory,
681 }
682 }
683
684 pub fn print(&self, description: &str) {
685 println!("\n--- Backtest Summary: {} ---", description);
686 if self.total_trajectories == 1 {
687 println!("(Single Trajectory / Historical Data)");
688 println!("Total Return: {:.6}", self.mean_total_return);
689 println!("Sharpe Ratio: {:.4}", self.mean_sharpe_ratio);
690 println!("Sortino Ratio: {:.4}", self.mean_sortino_ratio);
691 println!("Max Drawdown: {:.4}", self.mean_max_drawdown);
692 println!("Mean Inventory: {:.2}", self.mean_inventory);
693 println!("Max Inventory: {:.0}", self.mean_max_inventory);
694 println!("Min Inventory: {:.0}", self.mean_min_inventory);
695 println!("Total Trades: {:.0}", self.mean_total_trades);
696 println!(
697 "Adverse Selection: {:.2} bps",
698 self.mean_adverse_selection_bps
699 );
700 println!(
701 "Realized Edge: {:.2} bps",
702 self.mean_realized_edge_bps
703 );
704 println!("Inventory Variance: {:.4}", self.mean_inventory_variance);
705 println!("PnL Spread: {:.4}", self.mean_pnl_spread);
706 println!("PnL Directional: {:.4}", self.mean_pnl_dir);
707 println!("Fill Asymmetry: {:.4}", self.mean_fill_asymmetry);
708 println!("Mean Hold Time: {:.2} steps", self.mean_hold_time);
709 } else {
710 println!("Trajectories: {}", self.total_trajectories);
711 println!("Mean Total Return: {:.6}", self.mean_total_return);
712 println!("Std Dev Return: {:.6}", self.std_dev_total_return);
713 println!("Mean Sharpe Ratio: {:.4}", self.mean_sharpe_ratio);
714 println!("Std Dev Sharpe: {:.4}", self.std_dev_sharpe_ratio);
715 println!("Mean Sortino Ratio: {:.4}", self.mean_sortino_ratio);
716 println!("Mean Max Drawdown: {:.4}", self.mean_max_drawdown);
717 println!("Mean Inventory: {:.2}", self.mean_inventory);
718 println!("Mean Max Inv: {:.2}", self.mean_max_inventory);
719 println!("Mean Min Inv: {:.2}", self.mean_min_inventory);
720 println!("Abs Max Inv: {:.0}", self.abs_max_inventory);
721 println!("Abs Min Inv: {:.0}", self.abs_min_inventory);
722 println!("Mean Trades: {:.1}", self.mean_total_trades);
723 println!(
724 "Mean Adverse Sel: {:.2} bps",
725 self.mean_adverse_selection_bps
726 );
727 println!(
728 "Mean Realized Edge: {:.2} bps",
729 self.mean_realized_edge_bps
730 );
731 println!("Inv Variance: {:.4}", self.mean_inventory_variance);
732 println!("PnL Spread: {:.4}", self.mean_pnl_spread);
733 println!("PnL Directional: {:.4}", self.mean_pnl_dir);
734 println!("Fill Asymmetry: {:.4}", self.mean_fill_asymmetry);
735 println!("Mean Hold Time: {:.2} steps", self.mean_hold_time);
736 }
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::matcher::SimpleMatcher;
744 use crate::strategies::Strategy;
745 use crate::types::{Observation, Order, OrderRequest};
746
747 struct FixedDataSource {
748 ticks: usize,
749 }
750 impl crate::data_source::DataSource for FixedDataSource {
751 fn next_quote(&mut self) -> Option<crate::types::MarketState> {
752 if self.ticks == 0 {
753 return None;
754 }
755 self.ticks -= 1;
756 Some(crate::types::MarketState {
757 timestamp: 0.0,
758 best_bid: 99.0,
759 best_ask: 101.0,
760 last_price: Some(100.0),
761 true_volatility: Some(0.2),
762 true_drift: None,
763 parameters: None,
764 })
765 }
766 }
767
768 struct SpreadCaptureStrategy {
769 counter: u64,
770 }
771 impl Strategy for SpreadCaptureStrategy {
772 fn on_tick(&mut self, obs: &Observation, requests: &mut Vec<OrderRequest>) {
773 self.counter += 1;
775 requests.push(OrderRequest::CancelAll);
776 requests.push(OrderRequest::New(Order::new(
777 self.counter * 2,
778 Side::Buy,
779 obs.best_ask + 0.01,
780 1.0,
781 )));
782 self.counter += 1;
783 requests.push(OrderRequest::New(Order::new(
784 self.counter * 2 + 1,
785 Side::Sell,
786 obs.best_bid - 0.01,
787 1.0,
788 )));
789 }
790
791 fn as_any(&self) -> &dyn std::any::Any {
792 self
793 }
794 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
795 self
796 }
797 }
798
799 #[test]
800 fn test_backtest_returns_valid_metrics() {
801 let matcher = SimpleMatcher::new();
802 let strategy = SpreadCaptureStrategy { counter: 0 };
803 let data_source = FixedDataSource { ticks: 100 };
804 let mut engine = crate::engine::Engine::new(matcher, strategy, data_source, 100000.0, 0.0);
805
806 let result = run_backtest(&mut engine, 1.0 / 252.0);
807
808 assert!(result.total_trades > 0);
809 assert!(result.volatility >= 0.0);
810 assert!(result.max_drawdown >= 0.0);
811 assert!(result.max_drawdown <= 1.0);
812 assert!(result.sortino_ratio.is_finite());
814 assert!(result.adverse_selection_bps.is_finite());
816 assert!(result.realized_edge_bps.is_finite());
817 }
818
819 #[test]
820 fn test_realized_edge_for_aggressive_trades() {
821 let matcher = SimpleMatcher::new();
824 let strategy = SpreadCaptureStrategy { counter: 0 };
825 let data_source = FixedDataSource { ticks: 50 };
826 let mut engine = crate::engine::Engine::new(matcher, strategy, data_source, 100000.0, 0.0);
827
828 let result = run_backtest(&mut engine, 1.0 / 252.0);
829
830 assert!(
832 result.realized_edge_bps < 0.0,
833 "Aggressive trades should have negative realized edge, got {:.2} bps",
834 result.realized_edge_bps
835 );
836 }
837
838 #[test]
839 fn test_backtest_summary() {
840 let results = vec![
841 BacktestResult {
842 total_return: 0.05,
843 annualized_return: 0.10,
844 volatility: 0.15,
845 sharpe_ratio: 0.67,
846 sortino_ratio: 0.80,
847 max_drawdown: 0.03,
848 total_trades: 100,
849 final_equity: 10500.0,
850 mean_inventory: 1.0,
851 max_inventory: 5.0,
852 min_inventory: -3.0,
853 adverse_selection_bps: -2.0,
854 realized_edge_bps: 5.0,
855 volatility_path: None,
856 inventory_variance: 2.0,
857 pnl_spread: 10.0,
858 pnl_dir: 3.0,
859 fill_buy_count: 50,
860 fill_sell_count: 50,
861 mean_hold_time: 5.0,
862 terminal_liquidation_cost: 0.0,
863 },
864 BacktestResult {
865 total_return: -0.02,
866 annualized_return: -0.04,
867 volatility: 0.20,
868 sharpe_ratio: -0.20,
869 sortino_ratio: -0.15,
870 max_drawdown: 0.08,
871 total_trades: 80,
872 final_equity: 9800.0,
873 mean_inventory: -0.5,
874 max_inventory: 3.0,
875 min_inventory: -4.0,
876 adverse_selection_bps: -5.0,
877 realized_edge_bps: 3.0,
878 volatility_path: None,
879 inventory_variance: 1.5,
880 pnl_spread: 8.0,
881 pnl_dir: -2.0,
882 fill_buy_count: 40,
883 fill_sell_count: 40,
884 mean_hold_time: 4.0,
885 terminal_liquidation_cost: 0.0,
886 },
887 ];
888
889 let summary = BacktestSummary::new(&results);
890 assert_eq!(summary.total_trajectories, 2);
891 assert!((summary.mean_total_return - 0.015).abs() < 1e-10);
892 assert!((summary.mean_adverse_selection_bps - (-3.5)).abs() < 1e-10);
893 assert!((summary.mean_realized_edge_bps - 4.0).abs() < 1e-10);
894 assert!((summary.mean_sortino_ratio - 0.325).abs() < 1e-10);
895 }
896}