Improve jq microcap execution semantics
This commit is contained in:
437
crates/fidc-core/src/metrics.rs
Normal file
437
crates/fidc-core/src/metrics.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::FillEvent;
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
const MONTHS_PER_YEAR: f64 = 12.0;
|
||||
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
pub total_return: f64,
|
||||
pub annual_return: f64,
|
||||
pub sharpe: f64,
|
||||
pub max_drawdown: f64,
|
||||
pub win_rate: f64,
|
||||
pub alpha: f64,
|
||||
pub beta: f64,
|
||||
pub benchmark_cumulative_return: f64,
|
||||
pub benchmark_net_value: f64,
|
||||
pub risk_free_rate: f64,
|
||||
pub monthly_excess_win_rate: f64,
|
||||
pub excess_cumulative_return: f64,
|
||||
pub excess_annual_return: f64,
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub total_trade_days: usize,
|
||||
pub sortino: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
pub excess_return: f64,
|
||||
pub excess_sharpe: f64,
|
||||
pub excess_volatility: f64,
|
||||
pub excess_max_drawdown: f64,
|
||||
pub holding_count: usize,
|
||||
pub average_weight: f64,
|
||||
pub max_weight: f64,
|
||||
pub concentration: f64,
|
||||
pub weight_std_dev: f64,
|
||||
pub median_weight: f64,
|
||||
pub average_daily_turnover: f64,
|
||||
pub total_assets: f64,
|
||||
pub cash_balance: f64,
|
||||
pub unit_nav: f64,
|
||||
pub initial_cash: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
daily_holdings: &[HoldingSummary],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
};
|
||||
let Some(last_point) = equity_curve.last() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
};
|
||||
|
||||
let trade_days = equity_curve.len();
|
||||
let returns = equity_curve
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity))
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_returns = equity_curve
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close))
|
||||
.collect::<Vec<_>>();
|
||||
let excess_returns = returns
|
||||
.iter()
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let benchmark_net_value = if first_point.benchmark_close.abs() < f64::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
last_point.benchmark_close / first_point.benchmark_close
|
||||
};
|
||||
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 excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
||||
|
||||
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
||||
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
||||
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
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::<Vec<_>>();
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, first_point.benchmark_close, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let excess_nav_series = equity_nav
|
||||
.iter()
|
||||
.zip(benchmark_nav_series.iter())
|
||||
.map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (max_drawdown, max_drawdown_duration_days) = drawdown_stats(&equity_nav);
|
||||
let (excess_max_drawdown, _) = drawdown_stats(&excess_nav_series);
|
||||
|
||||
let winning_days = returns.iter().filter(|value| **value > 0.0).count();
|
||||
let excess_winning_days = excess_returns.iter().filter(|value| **value > 0.0).count();
|
||||
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, |point| point.total_equity);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
.iter()
|
||||
.zip(monthly_benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let monthly_excess_win_rate = ratio(
|
||||
monthly_excess_returns
|
||||
.iter()
|
||||
.filter(|value| **value > 0.0)
|
||||
.count(),
|
||||
monthly_excess_returns.len(),
|
||||
);
|
||||
let monthly_sharpe = annualized_sharpe(
|
||||
&monthly_portfolio_returns,
|
||||
risk_free_rate / MONTHS_PER_YEAR,
|
||||
MONTHS_PER_YEAR,
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
|
||||
let turnover_by_date = fills.iter().fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||
acc
|
||||
});
|
||||
let equity_by_date = equity_curve
|
||||
.iter()
|
||||
.map(|point| (point.date, point.total_equity))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let average_daily_turnover = if equity_curve.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default();
|
||||
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ equity_curve.len() as f64
|
||||
};
|
||||
|
||||
let latest_date = last_point.date;
|
||||
let latest_holdings = daily_holdings
|
||||
.iter()
|
||||
.filter(|row| row.date == latest_date && row.quantity > 0)
|
||||
.collect::<Vec<_>>();
|
||||
let weights = latest_holdings
|
||||
.iter()
|
||||
.map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0))
|
||||
.collect::<Vec<_>>();
|
||||
let holding_count = latest_holdings.len();
|
||||
let average_weight = mean(&weights);
|
||||
let max_weight = weights
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0.0_f64, |acc, value| acc.max(value));
|
||||
let concentration = weights.iter().map(|weight| weight * weight).sum::<f64>();
|
||||
let weight_std_dev = std_dev(&weights);
|
||||
let median_weight = median(&weights);
|
||||
|
||||
let total_trade_days = equity_by_date.len();
|
||||
|
||||
BacktestMetrics {
|
||||
total_return,
|
||||
annual_return,
|
||||
sharpe,
|
||||
max_drawdown,
|
||||
win_rate,
|
||||
alpha,
|
||||
beta,
|
||||
benchmark_cumulative_return,
|
||||
benchmark_net_value,
|
||||
risk_free_rate,
|
||||
monthly_excess_win_rate,
|
||||
excess_cumulative_return,
|
||||
excess_annual_return,
|
||||
max_drawdown_duration_days,
|
||||
total_trade_days,
|
||||
sortino,
|
||||
information_ratio,
|
||||
tracking_error,
|
||||
volatility,
|
||||
excess_return,
|
||||
excess_sharpe,
|
||||
excess_volatility,
|
||||
excess_max_drawdown,
|
||||
holding_count,
|
||||
average_weight,
|
||||
max_weight,
|
||||
concentration,
|
||||
weight_std_dev,
|
||||
median_weight,
|
||||
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),
|
||||
initial_cash,
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_change(previous: f64, current: f64) -> f64 {
|
||||
if previous.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(current / previous) - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
||||
if periods == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let periods = periods as f64;
|
||||
let base = 1.0 + total_return;
|
||||
if base <= 0.0 {
|
||||
return -1.0;
|
||||
}
|
||||
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let mean_ret = mean(&adjusted);
|
||||
let std = std_dev(&adjusted);
|
||||
if std <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean_ret / std * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let downside = adjusted
|
||||
.iter()
|
||||
.filter(|value| **value < 0.0)
|
||||
.map(|value| value.powi(2))
|
||||
.collect::<Vec<_>>();
|
||||
if downside.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
||||
if downside_dev <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||
std_dev(values) * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let strategy_excess = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let benchmark_excess = benchmark_returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_strategy = mean(&strategy_excess);
|
||||
let mean_benchmark = mean(&benchmark_excess);
|
||||
let variance_benchmark = variance(&benchmark_excess);
|
||||
if variance_benchmark <= f64::EPSILON {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let covariance = strategy_excess
|
||||
.iter()
|
||||
.zip(benchmark_excess.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
||||
.sum::<f64>()
|
||||
/ (strategy_excess.len() - 1) as f64;
|
||||
let beta = covariance / variance_benchmark;
|
||||
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
|
||||
(alpha, beta)
|
||||
}
|
||||
|
||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
let mut peak = 0.0_f64;
|
||||
let mut max_drawdown = 0.0_f64;
|
||||
let mut duration = 0_usize;
|
||||
let mut max_duration = 0_usize;
|
||||
for value in nav {
|
||||
if *value >= peak {
|
||||
peak = *value;
|
||||
duration = 0;
|
||||
continue;
|
||||
}
|
||||
if peak > f64::EPSILON {
|
||||
let drawdown = (*value / peak) - 1.0;
|
||||
if drawdown < max_drawdown {
|
||||
max_drawdown = drawdown;
|
||||
}
|
||||
}
|
||||
duration += 1;
|
||||
if duration > max_duration {
|
||||
max_duration = duration;
|
||||
}
|
||||
}
|
||||
(max_drawdown, max_duration)
|
||||
}
|
||||
|
||||
fn group_monthly_returns<F>(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec<f64>
|
||||
where
|
||||
F: Fn(&DailyEquityPoint) -> f64,
|
||||
{
|
||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||
for point in equity_curve {
|
||||
let key = (point.date.year(), point.date.month());
|
||||
month_first.entry(key).or_insert_with(|| value_fn(point));
|
||||
month_last.insert(key, value_fn(point));
|
||||
}
|
||||
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 mean(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
values.iter().sum::<f64>() / values.len() as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn variance(values: &[f64]) -> f64 {
|
||||
if values.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let avg = mean(values);
|
||||
values
|
||||
.iter()
|
||||
.map(|value| (value - avg).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (values.len() - 1) as f64
|
||||
}
|
||||
|
||||
fn std_dev(values: &[f64]) -> f64 {
|
||||
variance(values).sqrt()
|
||||
}
|
||||
|
||||
fn median(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = sorted.len() / 2;
|
||||
if sorted.len() % 2 == 0 {
|
||||
(sorted[mid - 1] + sorted[mid]) / 2.0
|
||||
} else {
|
||||
sorted[mid]
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(numerator: usize, denominator: usize) -> f64 {
|
||||
if denominator == 0 {
|
||||
0.0
|
||||
} else {
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
||||
if denominator.abs() < f64::EPSILON {
|
||||
fallback
|
||||
} else {
|
||||
numerator / denominator
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user