Merge remote-tracking branch 'origin/main'

# Conflicts:
#	crates/fidc-core/src/metrics.rs
This commit is contained in:
boris
2026-08-22 18:58:44 +08:00
9 changed files with 169 additions and 27 deletions
+121 -24
View File
@@ -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;
@@ -61,6 +61,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 {
@@ -86,14 +87,26 @@ pub fn compute_backtest_metrics(
} else {
first_point.benchmark_close
};
let nav_series = equity_curve
.iter()
.map(|point| point_nav(point, initial_cash))
.collect::<Vec<_>>();
let mut returns = Vec::with_capacity(nav_series.len());
returns.push(pct_change(1.0, nav_series[0]));
let explicit_unit_nav = equity_curve.iter().any(|point| {
point.external_cash_flow.abs() > f64::EPSILON
|| (point.unit_nav.is_finite()
&& point.unit_nav > 0.0
&& (point.unit_nav - safe_div(point.total_equity, initial_cash, 1.0)).abs() > 1e-12)
});
let portfolio_nav = if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point_nav(point, initial_cash))
.collect::<Vec<_>>()
} else {
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(
nav_series
portfolio_nav
.windows(2)
.map(|window| pct_change(window[0], window[1])),
);
@@ -116,12 +129,12 @@ pub fn compute_backtest_metrics(
last_point.benchmark_close / benchmark_start
};
let benchmark_cumulative_return = benchmark_net_value - 1.0;
let final_nav = *nav_series.last().unwrap_or(&1.0);
let final_nav = portfolio_nav.last().copied().unwrap_or(1.0);
let total_return = final_nav - 1.0;
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
total_return
} else {
final_nav / 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);
@@ -138,7 +151,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 = nav_series;
let equity_nav = portfolio_nav;
let benchmark_nav_series = equity_curve
.iter()
.map(|point| safe_div(point.benchmark_close, benchmark_start, 1.0))
@@ -157,8 +170,7 @@ pub fn compute_backtest_metrics(
let win_rate = ratio(winning_days, returns.len());
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
let monthly_portfolio_returns =
group_monthly_returns(equity_curve, 1.0, |point| point_nav(point, initial_cash));
let monthly_portfolio_returns = 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
@@ -262,10 +274,14 @@ pub fn compute_backtest_metrics(
cash_balance: last_point.cash,
unit_nav: final_nav,
initial_cash,
external_cash_flow_total: equity_curve
.iter()
.map(|point| point.external_cash_flow)
.sum(),
external_cash_flow_total: if explicit_unit_nav {
equity_curve
.iter()
.map(|point| point.external_cash_flow)
.sum()
} else {
external_flow_total_from_events(account_events)
},
excess_win_rate,
monthly_sharpe,
monthly_volatility,
@@ -399,6 +415,80 @@ 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<f64> {
let mut external_flow_by_date = BTreeMap::<NaiveDate, f64>::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 external_flow_total_from_events(account_events: &[AccountEvent]) -> f64 {
account_events
.iter()
.filter(|event| {
event.note.starts_with("deposit_withdraw amount=")
|| event.note.starts_with("deposit_withdraw_settled amount=")
})
.map(|event| event.cash_after - event.cash_before)
.sum()
}
fn group_monthly_returns_from_values(
equity_curve: &[DailyEquityPoint],
values: &[f64],
) -> Vec<f64> {
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::<Vec<_>>();
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<F>(
equity_curve: &[DailyEquityPoint],
initial_value: f64,
@@ -516,7 +606,7 @@ 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);
}
@@ -527,20 +617,27 @@ mod tests {
equity_point("2025-01-02", 100.0, 100.0, 100.0),
DailyEquityPoint {
date: NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(),
cash: 200.0,
cash: 220.0,
market_value: 0.0,
total_equity: 200.0,
total_equity: 220.0,
external_cash_flow: 100.0,
unit_nav: 1.0,
unit_nav: 1.1,
benchmark_close: 100.0,
benchmark_prev_close: 100.0,
notes: String::new(),
diagnostics: String::new(),
},
];
let metrics = compute_backtest_metrics(&curve, &[], &[], 100.0);
assert!((metrics.total_return - 0.0).abs() < 1e-12);
assert!((metrics.unit_nav - 1.0).abs() < 1e-12);
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);
assert!((metrics.external_cash_flow_total - 100.0).abs() < 1e-12);
}
}