use chrono::{DateTime, NaiveDateTime, Utc};
use super::{EquityPoint, PerformanceMetrics};
use crate::backtesting::position::Trade;
pub(super) struct TradeStats {
pub(super) winning_trades: usize,
pub(super) losing_trades: usize,
pub(super) long_trades: usize,
pub(super) short_trades: usize,
pub(super) gross_profit: f64,
pub(super) gross_loss: f64,
pub(super) total_return_sum: f64,
pub(super) total_duration: i64,
pub(super) largest_win: f64,
pub(super) largest_loss: f64,
pub(super) total_commission: f64,
pub(super) total_financing_cost: f64,
pub(super) total_dividend_income: f64,
pub(super) winning_returns: Vec<f64>,
pub(super) losing_returns: Vec<f64>,
pub(super) all_returns: Vec<f64>,
}
pub(super) fn analyze_trades(trades: &[Trade]) -> TradeStats {
let mut stats = TradeStats {
winning_trades: 0,
losing_trades: 0,
long_trades: 0,
short_trades: 0,
gross_profit: 0.0,
gross_loss: 0.0,
total_return_sum: 0.0,
total_duration: 0,
largest_win: 0.0,
largest_loss: 0.0,
total_commission: 0.0,
total_financing_cost: 0.0,
total_dividend_income: 0.0,
winning_returns: Vec::new(),
losing_returns: Vec::new(),
all_returns: Vec::new(),
};
for t in trades {
if t.is_profitable() {
stats.winning_trades += 1;
stats.gross_profit += t.pnl;
stats.winning_returns.push(t.return_pct);
stats.largest_win = stats.largest_win.max(t.pnl);
} else if t.is_loss() {
stats.losing_trades += 1;
stats.gross_loss += t.pnl.abs();
stats.losing_returns.push(t.return_pct);
stats.largest_loss = stats.largest_loss.min(t.pnl);
}
if t.is_long() {
stats.long_trades += 1;
} else {
stats.short_trades += 1;
}
stats.total_return_sum += t.return_pct;
stats.total_duration += t.duration_secs();
stats.total_commission += t.commission;
stats.total_financing_cost += t.financing_cost;
stats.total_dividend_income += t.dividend_income;
stats.all_returns.push(t.return_pct);
}
stats
}
pub(super) fn calculate_kelly(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
crate::perf_metrics::kelly_criterion(win_rate, avg_win_pct, avg_loss_pct)
}
pub(super) fn calculate_sqn(returns: &[f64]) -> f64 {
let n = returns.len();
if n < 2 {
return 0.0;
}
let mean = returns.iter().sum::<f64>() / n as f64;
let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let std_dev = variance.sqrt();
if std_dev == 0.0 {
return 0.0;
}
(mean / std_dev) * (n as f64).sqrt()
}
pub(super) fn calculate_omega_ratio(returns: &[f64]) -> f64 {
crate::perf_metrics::omega_ratio(returns)
}
pub(super) fn calculate_tail_ratio(returns: &[f64]) -> f64 {
let n = returns.len();
if n < 2 {
return 0.0;
}
let mut sorted = returns.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p5_idx = ((0.05 * n as f64).floor() as usize).min(n - 1);
let p95_idx = ((0.95 * n as f64).floor() as usize).min(n - 1);
let p5 = sorted[p5_idx].abs();
let p95 = sorted[p95_idx].abs();
if p5 == 0.0 {
if p95 > 0.0 { f64::MAX } else { 0.0 }
} else {
p95 / p5
}
}
pub(super) fn calculate_ulcer_index(equity_curve: &[EquityPoint]) -> f64 {
let drawdowns: Vec<f64> = equity_curve.iter().map(|p| p.drawdown_pct).collect();
crate::perf_metrics::ulcer_index(&drawdowns)
}
pub(super) fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
let mut max_wins = 0;
let mut max_losses = 0;
let mut current_wins = 0;
let mut current_losses = 0;
for trade in trades {
if trade.is_profitable() {
current_wins += 1;
current_losses = 0;
max_wins = max_wins.max(current_wins);
} else if trade.is_loss() {
current_losses += 1;
current_wins = 0;
max_losses = max_losses.max(current_losses);
} else {
current_wins = 0;
current_losses = 0;
}
}
(max_wins, max_losses)
}
pub(super) fn calculate_max_drawdown_duration(equity_curve: &[EquityPoint]) -> i64 {
if equity_curve.is_empty() {
return 0;
}
let mut max_duration = 0;
let mut current_duration = 0;
let mut peak = equity_curve[0].equity;
for point in equity_curve {
if point.equity >= peak {
peak = point.equity;
max_duration = max_duration.max(current_duration);
current_duration = 0;
} else {
current_duration += 1;
}
}
max_duration.max(current_duration)
}
pub(super) fn calculate_periodic_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
if equity_curve.len() < 2 {
return vec![];
}
equity_curve
.windows(2)
.map(|w| {
let prev = w[0].equity;
let curr = w[1].equity;
if prev > 0.0 {
(curr - prev) / prev
} else {
0.0
}
})
.collect()
}
fn annual_to_periodic_rf(annual_rate: f64, bars_per_year: f64) -> f64 {
(1.0 + annual_rate).powf(1.0 / bars_per_year) - 1.0
}
pub(super) fn calculate_risk_ratios(
returns: &[f64],
annual_risk_free_rate: f64,
bars_per_year: f64,
) -> (f64, f64) {
if returns.len() < 2 {
return (0.0, 0.0);
}
let periodic_rf = annual_to_periodic_rf(annual_risk_free_rate, bars_per_year);
let n = returns.len() as f64;
let mean = returns.iter().map(|r| r - periodic_rf).sum::<f64>() / n;
let (var_sum, downside_sq_sum) = returns.iter().fold((0.0_f64, 0.0_f64), |(v, d), &r| {
let e = r - periodic_rf;
let delta = e - mean;
(v + delta * delta, if e < 0.0 { d + e * e } else { d })
});
let std_dev = (var_sum / (n - 1.0)).sqrt();
let sharpe = if std_dev > 0.0 {
(mean / std_dev) * bars_per_year.sqrt()
} else if mean > 0.0 {
f64::MAX
} else {
0.0
};
let downside_dev = (downside_sq_sum / (n - 1.0)).sqrt();
let sortino = if downside_dev > 0.0 {
(mean / downside_dev) * bars_per_year.sqrt()
} else if mean > 0.0 {
f64::MAX
} else {
0.0
};
(sharpe, sortino)
}
pub(super) fn calculate_win_loss_durations(trades: &[Trade]) -> (f64, f64) {
let (win_sum, win_count, loss_sum, loss_count) =
trades
.iter()
.fold((0i64, 0usize, 0i64, 0usize), |(ws, wc, ls, lc), t| {
if t.is_profitable() {
(ws + t.duration_secs(), wc + 1, ls, lc)
} else if t.is_loss() {
(ws, wc, ls + t.duration_secs(), lc + 1)
} else {
(ws, wc, ls, lc)
}
});
let avg_win = if win_count == 0 {
0.0
} else {
win_sum as f64 / win_count as f64
};
let avg_loss = if loss_count == 0 {
0.0
} else {
loss_sum as f64 / loss_count as f64
};
(avg_win, avg_loss)
}
pub(super) fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
let mut total_duration_secs: i64 = 0;
let mut current: Option<(i64, i64)> = None;
for t in trades {
match &mut current {
Some((entry, max_exit)) if *entry == t.entry_timestamp => {
*max_exit = (*max_exit).max(t.exit_timestamp);
}
_ => {
if let Some((entry, max_exit)) = current {
total_duration_secs += max_exit - entry;
}
current = Some((t.entry_timestamp, t.exit_timestamp));
}
}
}
if let Some((entry, max_exit)) = current {
total_duration_secs += max_exit - entry;
}
let backtest_secs = match (equity_curve.first(), equity_curve.last()) {
(Some(first), Some(last)) if last.timestamp > first.timestamp => {
last.timestamp - first.timestamp
}
_ => return 0.0,
};
(total_duration_secs as f64 / backtest_secs as f64).min(1.0)
}
pub(super) fn calculate_max_idle_period(trades: &[Trade]) -> i64 {
if trades.len() < 2 {
return 0;
}
trades
.windows(2)
.map(|w| (w[1].entry_timestamp - w[0].exit_timestamp).max(0))
.max()
.unwrap_or(0)
}
pub(super) fn infer_bars_per_year(equity_slice: &[EquityPoint], fallback_bpy: f64) -> f64 {
if equity_slice.len() < 2 {
return fallback_bpy;
}
let first_ts = equity_slice.first().unwrap().timestamp as f64;
let last_ts = equity_slice.last().unwrap().timestamp as f64;
let seconds_per_year = 365.25 * 24.0 * 3600.0;
let years = (last_ts - first_ts) / seconds_per_year;
if years <= 0.0 {
return fallback_bpy;
}
((equity_slice.len() - 1) as f64 / years).max(1.0)
}
pub(super) fn partial_period_adjust(
mut metrics: PerformanceMetrics,
slice_len: usize,
bpy: f64,
) -> PerformanceMetrics {
let periods = slice_len.saturating_sub(1) as f64;
if periods / bpy < 0.5 {
metrics.annualized_return_pct = 0.0;
metrics.calmar_ratio = 0.0;
metrics.serenity_ratio = 0.0;
}
metrics
}
pub(super) fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
}
#[cfg(test)]
mod tests {
use super::super::fixtures::{equity_point, make_trade, ts};
use super::*;
#[test]
fn test_consecutive_wins_losses() {
let trades = vec![
make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true), make_trade(25.0, 2.5, true), make_trade(-50.0, -5.0, true), make_trade(-25.0, -2.5, true), make_trade(100.0, 10.0, true), ];
let (max_wins, max_losses) = calculate_consecutive(&trades);
assert_eq!(max_wins, 3);
assert_eq!(max_losses, 2);
}
#[test]
fn test_drawdown_duration() {
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 100.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 95.0,
drawdown_pct: 0.05,
},
EquityPoint {
timestamp: 2,
equity: 90.0,
drawdown_pct: 0.10,
},
EquityPoint {
timestamp: 3,
equity: 92.0,
drawdown_pct: 0.08,
},
EquityPoint {
timestamp: 4,
equity: 100.0,
drawdown_pct: 0.0,
}, EquityPoint {
timestamp: 5,
equity: 98.0,
drawdown_pct: 0.02,
},
];
let duration = calculate_max_drawdown_duration(&equity);
assert_eq!(duration, 3); }
#[test]
fn test_sharpe_uses_sample_variance() {
let returns = vec![0.01, -0.01, 0.02, -0.02];
let (sharpe, _) = calculate_risk_ratios(&returns, 0.0, 252.0);
assert!(
(sharpe).abs() < 1e-10,
"Sharpe of zero-mean returns should be 0, got {}",
sharpe
);
}
#[test]
fn test_kelly_criterion() {
let kelly = calculate_kelly(0.6, 10.0, -5.0);
assert!(
(kelly - 0.4).abs() < 1e-9,
"Kelly should be 0.4, got {kelly}"
);
assert_eq!(calculate_kelly(1.0, 10.0, 0.0), f64::MAX);
assert_eq!(calculate_kelly(0.0, 0.0, 0.0), 0.0);
let kelly_neg = calculate_kelly(0.3, 5.0, -5.0);
assert!(
(kelly_neg - (-0.4)).abs() < 1e-9,
"Kelly should be -0.4, got {kelly_neg}"
);
}
#[test]
fn test_sqn() {
let returns = vec![1.0; 10];
assert_eq!(calculate_sqn(&returns), 0.0);
assert_eq!(calculate_sqn(&[1.0]), 0.0);
assert_eq!(calculate_sqn(&[]), 0.0);
let returns2 = vec![2.0, -1.0, 3.0, -1.0, 2.0];
let sqn = calculate_sqn(&returns2);
assert!(
(sqn - 1.1952).abs() < 0.001,
"SQN should be ~1.195, got {sqn}"
);
}
#[test]
fn test_omega_ratio() {
assert_eq!(calculate_omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
assert_eq!(calculate_omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
let omega = calculate_omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
assert!(
(omega - 5.0 / 3.0).abs() < 1e-9,
"Omega should be 5/3, got {omega}"
);
}
#[test]
fn test_tail_ratio() {
assert_eq!(calculate_tail_ratio(&[1.0]), 0.0);
let mut vals = vec![1.0f64; 16];
vals.extend([-10.0, -5.0, 5.0, 10.0]);
vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
let tr = calculate_tail_ratio(&vals);
assert!(
(tr - 2.0).abs() < 1e-9,
"Tail ratio should be 2.0, got {tr}"
);
let zeros_with_win = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 5.0,
];
assert_eq!(calculate_tail_ratio(&zeros_with_win), f64::MAX);
}
#[test]
fn test_ulcer_index() {
let flat = vec![
EquityPoint {
timestamp: 0,
equity: 100.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 110.0,
drawdown_pct: 0.0,
},
];
assert_eq!(calculate_ulcer_index(&flat), 0.0);
let dd = vec![
EquityPoint {
timestamp: 0,
equity: 100.0,
drawdown_pct: 0.1,
},
EquityPoint {
timestamp: 1,
equity: 90.0,
drawdown_pct: 0.2,
},
];
let ui = calculate_ulcer_index(&dd);
let expected = ((100.0f64 + 400.0) / 2.0).sqrt(); assert!(
(ui - expected).abs() < 1e-9,
"Ulcer index should be {expected}, got {ui}"
);
}
#[test]
fn partial_period_adjust_zeroes_annualised_fields_for_short_slice() {
let dummy_metrics = PerformanceMetrics::calculate(
&[make_trade(100.0, 10.0, true)],
&[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
10000.0,
0,
0,
0.0,
252.0,
);
assert!(dummy_metrics.annualized_return_pct != 0.0);
let adjusted = partial_period_adjust(dummy_metrics, 10, 252.0);
assert_eq!(adjusted.annualized_return_pct, 0.0);
assert_eq!(adjusted.calmar_ratio, 0.0);
assert_eq!(adjusted.serenity_ratio, 0.0);
}
#[test]
fn partial_period_adjust_preserves_full_year_metrics() {
let metrics = PerformanceMetrics::calculate(
&[make_trade(100.0, 10.0, true)],
&[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
10000.0,
0,
0,
0.0,
252.0,
);
let ann_before = metrics.annualized_return_pct;
let adjusted = partial_period_adjust(metrics, 252, 252.0);
assert_eq!(adjusted.annualized_return_pct, ann_before);
}
#[test]
fn infer_bars_per_year_approximates_weekly_for_monday_subset() {
let base = ts("2023-01-02");
let week_secs = 7 * 86400i64;
let pts: Vec<EquityPoint> = (0..104)
.map(|i| equity_point(base + i * week_secs, 10000.0, 0.0))
.collect();
let bpy = infer_bars_per_year(&pts, 252.0);
assert!(bpy > 48.0 && bpy < 56.0, "expected ~52, got {bpy}");
}
#[test]
fn calculate_time_in_market_ignores_partial_scale_out_overlap() {
use crate::backtesting::position::PositionSide;
use crate::backtesting::signal::Signal;
let partial = Trade {
side: PositionSide::Long,
entry_timestamp: 0,
exit_timestamp: 5,
entry_price: 100.0,
exit_price: 105.0,
quantity: 5.0,
entry_quantity: 10.0,
commission: 0.0,
transaction_tax: 0.0,
pnl: 25.0,
return_pct: 5.0,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
financing_cost: 0.0,
tags: Vec::new(),
is_partial: true,
scale_sequence: 0,
entry_signal: Signal::long(0, 100.0),
exit_signal: Signal::exit(5, 105.0),
};
let final_trade = Trade {
side: PositionSide::Long,
entry_timestamp: 0,
exit_timestamp: 10,
entry_price: 100.0,
exit_price: 110.0,
quantity: 5.0,
entry_quantity: 10.0,
commission: 0.0,
transaction_tax: 0.0,
pnl: 50.0,
return_pct: 10.0,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
financing_cost: 0.0,
tags: Vec::new(),
is_partial: false,
scale_sequence: 0,
entry_signal: Signal::long(0, 100.0),
exit_signal: Signal::exit(10, 110.0),
};
let equity = vec![
equity_point(0, 10000.0, 0.0),
equity_point(100, 10075.0, 0.0),
];
let fraction = calculate_time_in_market(&[partial, final_trade], &equity);
assert!(
(fraction - 0.10).abs() < 1e-9,
"expected 0.10, got {fraction}"
);
}
#[test]
fn calculate_time_in_market_counts_a_position_with_only_partial_trades() {
use crate::backtesting::position::PositionSide;
use crate::backtesting::signal::Signal;
let partial = Trade {
side: PositionSide::Long,
entry_timestamp: 0,
exit_timestamp: 20,
entry_price: 100.0,
exit_price: 105.0,
quantity: 5.0,
entry_quantity: 5.0,
commission: 0.0,
transaction_tax: 0.0,
pnl: 25.0,
return_pct: 5.0,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
financing_cost: 0.0,
tags: Vec::new(),
is_partial: true,
scale_sequence: 0,
entry_signal: Signal::long(0, 100.0),
exit_signal: Signal::exit(20, 105.0),
};
let equity = vec![
equity_point(0, 10000.0, 0.0),
equity_point(100, 10025.0, 0.0),
];
let fraction = calculate_time_in_market(&[partial], &equity);
assert!(
(fraction - 0.20).abs() < 1e-9,
"expected 0.20, got {fraction}"
);
}
}