diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 2f85f60..e9d9b23 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2908,6 +2908,7 @@ where &result.equity_curve, &result.fills, &result.daily_holdings, + &result.account_events, self.aggregate_initial_cash(), ); diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index e21be97..b8686a4 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -4,7 +4,7 @@ use chrono::{Datelike, NaiveDate}; use serde::{Deserialize, Serialize}; use crate::engine::DailyEquityPoint; -use crate::events::FillEvent; +use crate::events::{AccountEvent, FillEvent}; use crate::portfolio::HoldingSummary; const TRADING_DAYS_PER_YEAR: f64 = 252.0; @@ -56,6 +56,7 @@ pub fn compute_backtest_metrics( equity_curve: &[DailyEquityPoint], fills: &[FillEvent], daily_holdings: &[HoldingSummary], + account_events: &[AccountEvent], initial_cash: f64, ) -> BacktestMetrics { let Some(first_point) = equity_curve.first() else { @@ -81,12 +82,15 @@ pub fn compute_backtest_metrics( } else { first_point.benchmark_close }; - let mut returns = Vec::with_capacity(equity_curve.len()); - returns.push(pct_change(initial_cash, first_point.total_equity)); + let portfolio_nav = flow_neutral_nav_series(equity_curve, account_events, initial_cash); + let mut returns = Vec::with_capacity(portfolio_nav.len()); + if let Some(first_nav) = portfolio_nav.first().copied() { + returns.push(pct_change(1.0, first_nav)); + } returns.extend( - equity_curve + portfolio_nav .windows(2) - .map(|window| pct_change(window[0].total_equity, window[1].total_equity)), + .map(|window| pct_change(window[0], window[1])), ); let mut benchmark_returns = Vec::with_capacity(equity_curve.len()); benchmark_returns.push(pct_change(benchmark_start, first_point.benchmark_close)); @@ -107,15 +111,11 @@ pub fn compute_backtest_metrics( last_point.benchmark_close / benchmark_start }; let benchmark_cumulative_return = benchmark_net_value - 1.0; - let total_return = if initial_cash.abs() < f64::EPSILON { - 0.0 - } else { - (last_point.total_equity / initial_cash) - 1.0 - }; + let total_return = portfolio_nav.last().copied().unwrap_or(0.0) - 1.0; let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON { total_return } else { - (last_point.total_equity / initial_cash) / benchmark_net_value - 1.0 + portfolio_nav.last().copied().unwrap_or(0.0) / benchmark_net_value - 1.0 }; let excess_return = total_return - benchmark_cumulative_return; let annual_return = annualize_return(total_return, trade_days); @@ -132,10 +132,7 @@ pub fn compute_backtest_metrics( let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf); - let equity_nav = equity_curve - .iter() - .map(|point| safe_div(point.total_equity, initial_cash, 1.0)) - .collect::>(); + let equity_nav = portfolio_nav; let benchmark_nav_series = equity_curve .iter() .map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0)) @@ -155,7 +152,7 @@ pub fn compute_backtest_metrics( let excess_win_rate = ratio(excess_winning_days, excess_returns.len()); let monthly_portfolio_returns = - group_monthly_returns(equity_curve, initial_cash, |point| point.total_equity); + group_monthly_returns_from_values(equity_curve, &equity_nav); let monthly_benchmark_returns = group_monthly_returns(equity_curve, benchmark_start, |point| point.benchmark_close); let monthly_excess_returns = monthly_portfolio_returns @@ -257,7 +254,7 @@ pub fn compute_backtest_metrics( average_daily_turnover, total_assets: last_point.total_equity, cash_balance: last_point.cash, - unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0), + unit_nav: equity_nav.last().copied().unwrap_or_default(), initial_cash, excess_win_rate, monthly_sharpe, @@ -384,6 +381,69 @@ fn drawdown_stats(nav: &[f64]) -> (f64, usize) { (max_drawdown, max_duration) } +fn flow_neutral_nav_series( + equity_curve: &[DailyEquityPoint], + account_events: &[AccountEvent], + initial_cash: f64, +) -> Vec { + let mut external_flow_by_date = BTreeMap::::new(); + for event in account_events { + if !(event.note.starts_with("deposit_withdraw amount=") + || event.note.starts_with("deposit_withdraw_settled amount=")) + { + continue; + } + *external_flow_by_date.entry(event.date).or_default() += + event.cash_after - event.cash_before; + } + + let mut units = initial_cash; + let mut previous_equity = initial_cash; + let mut navs = Vec::with_capacity(equity_curve.len()); + for point in equity_curve { + let unit_nav_before_flow = safe_div(previous_equity, units, 1.0); + let external_flow = external_flow_by_date + .get(&point.date) + .copied() + .unwrap_or_default(); + if external_flow.abs() > f64::EPSILON && unit_nav_before_flow.is_finite() { + units += external_flow / unit_nav_before_flow; + } + let unit_nav = safe_div(point.total_equity, units, 0.0); + navs.push(unit_nav); + previous_equity = point.total_equity; + } + navs +} + +fn group_monthly_returns_from_values( + equity_curve: &[DailyEquityPoint], + values: &[f64], +) -> Vec { + let mut month_last = BTreeMap::<(i32, u32), f64>::new(); + let mut month_first = BTreeMap::<(i32, u32), f64>::new(); + let mut previous_value = 1.0; + for (point, value) in equity_curve.iter().zip(values.iter().copied()) { + let key = (point.date.year(), point.date.month()); + month_first.entry(key).or_insert(previous_value); + month_last.insert(key, value); + previous_value = value; + } + let mut keys = month_last.keys().copied().collect::>(); + keys.sort_unstable(); + keys.into_iter() + .filter_map(|key| { + let first = month_first.get(&key).copied().unwrap_or_default(); + let last = month_last.get(&key).copied().unwrap_or_default(); + if first.abs() < f64::EPSILON { + None + } else { + Some((last / first) - 1.0) + } + }) + .collect() +} + fn group_monthly_returns( equity_curve: &[DailyEquityPoint], initial_value: f64, @@ -499,8 +559,26 @@ mod tests { equity_point("2025-01-02", 100.0, 5797.089, 5957.717), equity_point("2025-12-31", 120.0, 7595.285, 7597.299), ]; - let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0); + let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0); let expected = 7595.285 / 5957.717 - 1.0; assert!((metrics.benchmark_cumulative_return - expected).abs() < 1e-12); } + + #[test] + fn external_deposit_does_not_count_as_strategy_return() { + let curve = vec![ + equity_point("2025-01-02", 100.0, 100.0, 100.0), + equity_point("2025-01-03", 220.0, 100.0, 100.0), + ]; + let events = vec![AccountEvent { + date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(), + cash_before: 100.0, + cash_after: 200.0, + total_equity: 200.0, + note: "deposit_withdraw amount=100.00 reason=test".to_string(), + }]; + let metrics = compute_backtest_metrics(&curve, &[], &[], &events, 100.0); + assert!((metrics.total_return - 0.1).abs() < 1e-12); + assert!((metrics.unit_nav - 1.1).abs() < 1e-12); + } }