Skip to main content

engine/
backtest.rs

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    /// Var(q) = (1/T) sum (q_t - q_bar)^2
23    pub inventory_variance: f64,
24    /// PnL_spread = sum |P_i - S_{t_i}| in price units
25    pub pnl_spread: f64,
26    /// PnL_dir = sum q_{t-1} * (S_t - S_{t-1}) in price units
27    pub pnl_dir: f64,
28    /// Number of buy fills
29    pub fill_buy_count: usize,
30    /// Number of sell fills
31    pub fill_sell_count: usize,
32    /// Mean steps with non-zero inventory per hold episode
33    pub mean_hold_time: f64,
34    /// Terminal liquidation cost: |final_q| * half_spread (0 if not applied)
35    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
77/// Default adverse selection lookback (steps after fill).
78const DEFAULT_LOOKBACK: usize = 10;
79
80/// Run backtest with the default adverse selection lookback (10 steps).
81pub 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
89/// Run backtest with terminal liquidation applied uniformly at the end.
90/// `liquidation_half_spread` is the half-spread cost per unit of residual
91/// inventory: `|final_q| * liquidation_half_spread` is deducted from both
92/// `pnl_spread` and `final_equity` so all strategies are evaluated on equal
93/// footing regardless of whether they have their own terminal flattening logic.
94pub 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
111/// Run backtest with a custom adverse selection lookback for computing M(k).
112pub 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    // Statistics accumulators
141    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    // Directional PnL: sum q_{t-1} * (S_t - S_{t-1})
153    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    // Hold time: consecutive steps with non-zero inventory
158    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    // Fill-quality tracking
164    let mut mid_prices: Vec<f64> = Vec::with_capacity(5000);
165    // (step_index, side, fill_price, mid_at_fill)
166    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        // Collect fills from this step (for default agent)
185        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        // Mark to market
192        let equity = portfolio.cash + portfolio.position * price;
193
194        // Update Max Drawdown
195        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        // Update Inventory Stats
205        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        // Directional PnL: q_{t-1} * (S_t - S_{t-1})
217        // prev_pos is zero on iteration 0, so the first dS is ignored safely.
218        let d_price = price - prev_price;
219        pnl_dir_val += prev_pos * d_price;
220
221        // Hold time tracking: record run length when inventory returns to zero.
222        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        // Update Return Stats
232        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            // Downside deviation: only count negative returns
237            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    // Close any open hold episode at end of simulation.
250    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    // Apply uniform terminal liquidation cost if requested.
260    // Deduct |final_q| * half_spread from both pnl_spread and final_equity
261    // so that all strategies are penalised equally for residual inventory.
262    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    // Fill-quality metrics computed post-loop from fill_records.
315    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        // Adverse selection: post-trade mid movement (M(k) with k = lookback)
325        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        // Realized edge: |P_i - S_{t_i}| in bps (passive fill is profitable side)
338        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: signed passive-spread captured per fill.
347        // Positive for passive fills (bought below mid / sold above mid);
348        // negative for aggressive fills (crossed the spread as a taker).
349        // Using the same signed-edge convention as realized_edge_bps avoids
350        // double-counting aggressive fill COSTS as gains.
351        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    // Deduct terminal liquidation cost from spread PnL so the metric reflects
370    // the cost of crossing the spread to flatten residual inventory.
371    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    /// Mean Var(q) = (1/T) sum (q_t - q_bar)^2 across paths
419    pub mean_inventory_variance: f64,
420    /// Mean PnL_spread = sum |P_i - S_{t_i}| in price units
421    pub mean_pnl_spread: f64,
422    /// Mean PnL_dir = sum q_{t-1}*(S_t - S_{t-1}) in price units
423    pub mean_pnl_dir: f64,
424    /// Mean fill rate asymmetry N_buy / N_sell (0 if no sells)
425    pub mean_fill_asymmetry: f64,
426    /// Mean hold time (steps per non-zero inventory episode)
427    pub mean_hold_time: f64,
428    // ----- Per-metric standard deviations (across paths) -----
429    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    /// Mean of max(|q_max|, |q_min|) per path (distributional peak inventory)
440    pub mean_abs_peak_inventory: f64,
441    /// Std of abs peak inventory across paths
442    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        // Per-path absolute peak inventory: max(|q_max|, |q_min|) for each path.
545        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        // Fill asymmetry per path (filter inf before computing std).
622        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            // Place aggressive buy and sell every tick (crosses spread)
774            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        // Sortino should be computed (may be 0 if no downside)
813        assert!(result.sortino_ratio.is_finite());
814        // Adverse selection and realized edge should be finite
815        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        // Aggressive buys at ask (101), mid = 100 -> edge = 100 - 101 = -1 -> negative bps
822        // Aggressive sells at bid (99), mid = 100 -> edge = 99 - 100 = -1 -> negative bps
823        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        // Aggressive crossing should have negative realized edge (paying the spread)
831        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}