use crate::backtest::{BacktestRun, Trade};
use crate::metrics;
use serde::Serialize;
#[derive(Serialize)]
pub struct Metrics {
pub total_return: f64,
pub cagr: f64,
pub ann_volatility: f64,
pub sharpe: f64,
pub sortino: f64,
pub max_drawdown: f64,
pub calmar: f64,
pub win_rate: f64,
pub profit_factor: f64,
pub expectancy: f64,
pub avg_holding_period: f64,
pub num_trades: f64,
pub avg_win: f64,
pub avg_loss: f64,
pub payoff_ratio: f64,
pub best_trade: f64,
pub worst_trade: f64,
pub max_consecutive_losses: f64,
pub recovery_factor: f64,
pub max_drawdown_duration: f64,
pub time_in_market: f64,
pub avg_exposure: f64,
pub best_day: f64,
pub worst_day: f64,
pub skew: f64,
pub kurtosis: f64,
pub var_95: f64,
pub cvar_95: f64,
pub avg_drawdown: f64,
pub ulcer_index: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub ytd: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub one_year: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub three_year: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub benchmark_return: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alpha: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub beta: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excess_return: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tracking_error: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub information_ratio: Option<f64>,
}
#[derive(Serialize)]
pub struct Report {
pub dates: Vec<i32>,
pub equity: Vec<f64>,
pub drawdown: Vec<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub benchmark: Option<Vec<f64>>,
pub monthly_returns: Vec<metrics::PeriodReturn>,
pub yearly_returns: Vec<metrics::PeriodReturn>,
pub rolling_sharpe: Vec<f64>,
pub rolling_volatility: Vec<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bootstrap: Option<crate::bootstrap::BootstrapSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub live: Option<LiveSegment>,
pub trades: Vec<Trade>,
pub metrics: Metrics,
}
#[derive(Serialize)]
pub struct LiveSegment {
pub start: i32,
pub days: usize,
pub total_return: f64,
pub cagr: f64,
pub ann_volatility: f64,
pub sharpe: f64,
pub sortino: f64,
pub max_drawdown: f64,
pub calmar: f64,
}
pub fn live_segment(dates: &[i32], equity: &[f64], live_start: i32) -> Option<LiveSegment> {
let idx = dates.iter().position(|&d| d >= live_start)?;
let seg_dates = &dates[idx..];
let seg_eq = &equity[idx..];
Some(LiveSegment {
start: seg_dates[0],
days: seg_eq.len(),
total_return: metrics::total_return(seg_eq),
cagr: metrics::cagr(seg_eq, seg_dates),
ann_volatility: metrics::ann_volatility(seg_eq),
sharpe: metrics::sharpe(seg_eq),
sortino: metrics::sortino(seg_eq),
max_drawdown: metrics::max_drawdown(seg_eq),
calmar: metrics::calmar(seg_eq, seg_dates),
})
}
pub const ROLLING_WINDOW: usize = 252;
pub fn build_report(run: BacktestRun) -> Report {
build_report_with_benchmark(run, None)
}
pub fn build_report_with_benchmark(run: BacktestRun, benchmark: Option<Vec<f64>>) -> Report {
let eq = &run.equity;
let dates = &run.dates;
let bench = benchmark.as_deref();
let metrics = Metrics {
total_return: metrics::total_return(eq),
cagr: metrics::cagr(eq, dates),
ann_volatility: metrics::ann_volatility(eq),
sharpe: metrics::sharpe(eq),
sortino: metrics::sortino(eq),
max_drawdown: metrics::max_drawdown(eq),
calmar: metrics::calmar(eq, dates),
win_rate: metrics::win_rate(&run.trades),
profit_factor: metrics::profit_factor(&run.trades),
expectancy: metrics::expectancy(&run.trades),
avg_holding_period: metrics::avg_holding_period(&run.trades),
num_trades: metrics::num_trades(&run.trades),
avg_win: metrics::avg_win(&run.trades),
avg_loss: metrics::avg_loss(&run.trades),
payoff_ratio: metrics::payoff_ratio(&run.trades),
best_trade: metrics::best_trade(&run.trades),
worst_trade: metrics::worst_trade(&run.trades),
max_consecutive_losses: metrics::max_consecutive_losses(&run.trades),
recovery_factor: metrics::recovery_factor(eq),
max_drawdown_duration: metrics::max_drawdown_duration(eq),
time_in_market: metrics::time_in_market(&run.exposure),
avg_exposure: metrics::avg_exposure(&run.exposure),
best_day: metrics::best_day(eq),
worst_day: metrics::worst_day(eq),
skew: metrics::skew(eq),
kurtosis: metrics::kurtosis(eq),
var_95: metrics::var_95(eq),
cvar_95: metrics::cvar_95(eq),
avg_drawdown: metrics::avg_drawdown(eq),
ulcer_index: metrics::ulcer_index(eq),
ytd: metrics::ytd_return(dates, eq),
one_year: metrics::trailing_return(dates, eq, 1),
three_year: metrics::trailing_return(dates, eq, 3),
benchmark_return: bench.map(metrics::benchmark_return),
alpha: bench.map(|b| metrics::alpha(eq, b)),
beta: bench.map(|b| metrics::beta(eq, b)),
excess_return: bench.map(|b| metrics::total_return(eq) - metrics::benchmark_return(b)),
tracking_error: bench.map(|b| metrics::tracking_error(eq, b)),
information_ratio: bench.map(|b| metrics::information_ratio(eq, b)),
};
let drawdown = metrics::drawdown_series(eq);
let monthly_returns = metrics::monthly_returns(dates, eq);
let yearly_returns = metrics::yearly_returns(dates, eq);
let rolling_sharpe = metrics::rolling_sharpe(eq, ROLLING_WINDOW);
let rolling_volatility = metrics::rolling_volatility(eq, ROLLING_WINDOW);
Report {
dates: run.dates,
equity: run.equity,
drawdown,
benchmark,
monthly_returns,
yearly_returns,
rolling_sharpe,
rolling_volatility,
bootstrap: None,
live: None,
trades: run.trades,
metrics,
}
}
pub fn benchmark_equity(dates: &[i32], bench_dates: &[i32], bench_px: &[f64]) -> Vec<f64> {
let mut out = vec![f64::NAN; dates.len()];
let mut i = 0usize; let mut last = f64::NAN;
let mut base = f64::NAN;
for (r, d) in dates.iter().enumerate() {
while i < bench_dates.len() && bench_dates[i] <= *d {
if !bench_px[i].is_nan() {
last = bench_px[i];
if base.is_nan() && last != 0.0 {
base = last;
}
}
i += 1;
}
if !last.is_nan() && !base.is_nan() {
out[r] = last / base;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtest::{run, BacktestConfig};
use crate::panel::Panel;
#[test]
fn report_bundles_series_and_metrics_and_serializes() {
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![11.0], vec![12.0]],
)
.unwrap();
let report = build_report(run(&pos, &px, None, None, None, &BacktestConfig::default()));
assert_eq!(report.equity.len(), 3);
assert_eq!(report.drawdown.len(), 3);
assert!((report.metrics.total_return - 0.2).abs() < 1e-9);
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"sharpe\""));
assert!(json.contains("\"equity\""));
assert_eq!(report.metrics.num_trades, 0.0); assert!((report.metrics.avg_exposure - 1.0).abs() < 1e-9); assert!((report.metrics.time_in_market - 1.0).abs() < 1e-9);
assert!(json.contains("\"recovery_factor\""));
assert!(json.contains("\"max_drawdown_duration\""));
assert!(json.contains("\"max_consecutive_losses\""));
assert!(json.contains("\"best_day\""));
assert!(json.contains("\"var_95\""));
assert!(json.contains("\"cvar_95\""));
assert!(json.contains("\"ulcer_index\""));
assert!((report.metrics.best_day - 0.1).abs() < 1e-9); assert!(report.metrics.ytd.is_none());
assert!(report.metrics.one_year.is_none());
assert!(!json.contains("\"ytd\""));
assert!(!json.contains("\"one_year\""));
assert!(!json.contains("\"three_year\""));
assert!(!json.contains("\"benchmark\""));
assert!(!json.contains("\"alpha\""));
assert_eq!(report.monthly_returns.len(), 1); assert_eq!(report.monthly_returns[0].period, "2024-01");
assert!((report.monthly_returns[0].ret - 0.2).abs() < 1e-9);
assert_eq!(report.yearly_returns[0].period, "2024");
assert_eq!(report.rolling_sharpe.len(), 3); assert!(json.contains("\"monthly_returns\""));
assert!(json.contains("\"rolling_volatility\""));
}
#[test]
fn report_with_benchmark_adds_curve_and_relative_metrics() {
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![11.0], vec![12.0]],
)
.unwrap();
let r = run(&pos, &px, None, None, None, &BacktestConfig::default());
let bench = r.equity.clone();
let report = build_report_with_benchmark(r, Some(bench));
let m = &report.metrics;
assert!((m.beta.unwrap() - 1.0).abs() < 1e-9);
assert!(m.excess_return.unwrap().abs() < 1e-9);
assert!(m.tracking_error.unwrap().abs() < 1e-9);
assert!((m.benchmark_return.unwrap() - 0.2).abs() < 1e-9);
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"benchmark\""));
assert!(json.contains("\"information_ratio\""));
}
#[test]
fn live_segment_over_full_range_matches_full_sample_metrics() {
let dates = [20240102, 20240103, 20240104];
let eq = [100.0, 110.0, 121.0];
let seg = live_segment(&dates, &eq, 20240101).unwrap();
assert_eq!(seg.start, 20240102);
assert_eq!(seg.days, 3);
assert!((seg.total_return - metrics::total_return(&eq)).abs() < 1e-12);
assert!((seg.cagr - metrics::cagr(&eq, &dates)).abs() < 1e-12);
assert!((seg.max_drawdown - metrics::max_drawdown(&eq)).abs() < 1e-12);
assert!((seg.sharpe - metrics::sharpe(&eq)).abs() < 1e-12);
}
#[test]
fn live_segment_slices_from_first_date_on_or_after_start() {
let dates = [20240102, 20240103, 20240104];
let eq = [100.0, 110.0, 121.0];
let seg = live_segment(&dates, &eq, 20240103).unwrap();
assert_eq!(seg.start, 20240103);
assert_eq!(seg.days, 2);
assert!((seg.total_return - 0.1).abs() < 1e-12);
assert!(seg.max_drawdown.abs() < 1e-12); }
#[test]
fn live_segment_after_end_is_none() {
let dates = [20240102, 20240103];
let eq = [100.0, 110.0];
assert!(live_segment(&dates, &eq, 20250101).is_none());
}
#[test]
fn report_live_block_present_only_when_set() {
let dates = [20240102, 20240103, 20240104];
let eq = [100.0, 110.0, 121.0];
let pos = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![1.0], vec![1.0], vec![1.0]],
)
.unwrap();
let px = Panel::from_rows(
vec![20240102, 20240103, 20240104],
vec!["A".into()],
vec![vec![10.0], vec![11.0], vec![12.0]],
)
.unwrap();
let report = build_report(run(&pos, &px, None, None, None, &BacktestConfig::default()));
assert!(report.live.is_none());
assert!(!serde_json::to_string(&report).unwrap().contains("\"live\""));
let mut report = report;
report.live = live_segment(&dates, &eq, 20240103);
assert!(report.live.is_some());
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"live\""));
assert!(json.contains("\"total_return\""));
}
#[test]
fn benchmark_equity_aligns_ffills_and_rebases() {
let dates = [20240102, 20240103, 20240104, 20240105];
let bd = [20240103, 20240105];
let bp = [200.0, 220.0];
let eq = benchmark_equity(&dates, &bd, &bp);
assert!(eq[0].is_nan()); assert!((eq[1] - 1.0).abs() < 1e-12); assert!((eq[2] - 1.0).abs() < 1e-12); assert!((eq[3] - 1.1).abs() < 1e-12); }
}