use std::collections::HashMap;
use chrono::{DateTime, Datelike, NaiveDateTime, Utc, Weekday};
use serde::{Deserialize, Serialize};
use super::config::BacktestConfig;
use super::position::{Position, Trade};
use super::signal::SignalDirection;
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EquityPoint {
pub timestamp: i64,
pub equity: f64,
pub drawdown_pct: f64,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalRecord {
pub timestamp: i64,
pub price: f64,
pub direction: SignalDirection,
pub strength: f64,
pub reason: Option<String>,
pub executed: bool,
#[serde(default)]
pub tags: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceMetrics {
pub total_return_pct: f64,
pub annualized_return_pct: f64,
pub sharpe_ratio: f64,
pub sortino_ratio: f64,
pub max_drawdown_pct: f64,
pub max_drawdown_duration: i64,
pub win_rate: f64,
pub profit_factor: f64,
pub avg_trade_return_pct: f64,
pub avg_win_pct: f64,
pub avg_loss_pct: f64,
pub avg_trade_duration: f64,
pub total_trades: usize,
pub winning_trades: usize,
pub losing_trades: usize,
pub largest_win: f64,
pub largest_loss: f64,
pub max_consecutive_wins: usize,
pub max_consecutive_losses: usize,
pub calmar_ratio: f64,
pub total_commission: f64,
pub long_trades: usize,
pub short_trades: usize,
pub total_signals: usize,
pub executed_signals: usize,
pub avg_win_duration: f64,
pub avg_loss_duration: f64,
pub time_in_market_pct: f64,
pub max_idle_period: i64,
pub total_dividend_income: f64,
pub kelly_criterion: f64,
pub sqn: f64,
pub expectancy: f64,
pub omega_ratio: f64,
pub tail_ratio: f64,
pub recovery_factor: f64,
pub ulcer_index: f64,
pub serenity_ratio: f64,
}
impl PerformanceMetrics {
pub fn max_drawdown_percentage(&self) -> f64 {
self.max_drawdown_pct * 100.0
}
fn empty(
initial_capital: f64,
equity_curve: &[EquityPoint],
total_signals: usize,
executed_signals: usize,
) -> Self {
let final_equity = equity_curve
.last()
.map(|e| e.equity)
.unwrap_or(initial_capital);
let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
Self {
total_return_pct,
annualized_return_pct: 0.0,
sharpe_ratio: 0.0,
sortino_ratio: 0.0,
max_drawdown_pct: 0.0,
max_drawdown_duration: 0,
win_rate: 0.0,
profit_factor: 0.0,
avg_trade_return_pct: 0.0,
avg_win_pct: 0.0,
avg_loss_pct: 0.0,
avg_trade_duration: 0.0,
total_trades: 0,
winning_trades: 0,
losing_trades: 0,
largest_win: 0.0,
largest_loss: 0.0,
max_consecutive_wins: 0,
max_consecutive_losses: 0,
calmar_ratio: 0.0,
total_commission: 0.0,
long_trades: 0,
short_trades: 0,
total_signals,
executed_signals,
avg_win_duration: 0.0,
avg_loss_duration: 0.0,
time_in_market_pct: 0.0,
max_idle_period: 0,
total_dividend_income: 0.0,
kelly_criterion: 0.0,
sqn: 0.0,
expectancy: 0.0,
omega_ratio: 0.0,
tail_ratio: 0.0,
recovery_factor: 0.0,
ulcer_index: 0.0,
serenity_ratio: 0.0,
}
}
pub fn calculate(
trades: &[Trade],
equity_curve: &[EquityPoint],
initial_capital: f64,
total_signals: usize,
executed_signals: usize,
risk_free_rate: f64,
bars_per_year: f64,
) -> Self {
if trades.is_empty() {
return Self::empty(
initial_capital,
equity_curve,
total_signals,
executed_signals,
);
}
let total_trades = trades.len();
let stats = analyze_trades(trades);
let win_rate = stats.winning_trades as f64 / total_trades as f64;
let profit_factor = if stats.gross_loss > 0.0 {
stats.gross_profit / stats.gross_loss
} else if stats.gross_profit > 0.0 {
f64::MAX
} else {
0.0
};
let avg_trade_return_pct = stats.total_return_sum / total_trades as f64;
let avg_win_pct = if !stats.winning_returns.is_empty() {
stats.winning_returns.iter().sum::<f64>() / stats.winning_returns.len() as f64
} else {
0.0
};
let avg_loss_pct = if !stats.losing_returns.is_empty() {
stats.losing_returns.iter().sum::<f64>() / stats.losing_returns.len() as f64
} else {
0.0
};
let avg_trade_duration = stats.total_duration as f64 / total_trades as f64;
let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
let max_drawdown_pct = equity_curve
.iter()
.map(|e| e.drawdown_pct)
.fold(0.0, f64::max);
let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
let final_equity = equity_curve
.last()
.map(|e| e.equity)
.unwrap_or(initial_capital);
let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
let num_periods = equity_curve.len().saturating_sub(1);
let years = num_periods as f64 / bars_per_year;
let growth = final_equity / initial_capital;
let annualized_return_pct = if years > 0.0 {
if growth <= 0.0 {
-100.0
} else {
(growth.powf(1.0 / years) - 1.0) * 100.0
}
} else {
0.0
};
let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
let (sharpe_ratio, sortino_ratio) =
calculate_risk_ratios(&returns, risk_free_rate, bars_per_year);
let calmar_ratio = if max_drawdown_pct > 0.0 {
annualized_return_pct / (max_drawdown_pct * 100.0)
} else if annualized_return_pct > 0.0 {
f64::MAX
} else {
0.0
};
let (avg_win_duration, avg_loss_duration) = calculate_win_loss_durations(trades);
let time_in_market_pct = calculate_time_in_market(trades, equity_curve);
let max_idle_period = calculate_max_idle_period(trades);
let kelly_criterion = calculate_kelly(win_rate, avg_win_pct, avg_loss_pct);
let sqn = calculate_sqn(&stats.all_returns);
let loss_rate = stats.losing_trades as f64 / total_trades as f64;
let avg_win_dollar = if stats.winning_trades > 0 {
stats.gross_profit / stats.winning_trades as f64
} else {
0.0
};
let avg_loss_dollar = if stats.losing_trades > 0 {
-(stats.gross_loss / stats.losing_trades as f64)
} else {
0.0
};
let expectancy = win_rate * avg_win_dollar + loss_rate * avg_loss_dollar;
let omega_ratio = calculate_omega_ratio(&returns);
let tail_ratio = calculate_tail_ratio(&stats.all_returns);
let recovery_factor = if max_drawdown_pct > 0.0 {
total_return_pct / (max_drawdown_pct * 100.0)
} else if total_return_pct > 0.0 {
f64::MAX
} else {
0.0
};
let ulcer_index = calculate_ulcer_index(equity_curve);
let rf_pct = risk_free_rate * 100.0;
let serenity_ratio = if ulcer_index > 0.0 {
(annualized_return_pct - rf_pct) / ulcer_index
} else if annualized_return_pct > rf_pct {
f64::MAX
} else {
0.0
};
Self {
total_return_pct,
annualized_return_pct,
sharpe_ratio,
sortino_ratio,
max_drawdown_pct,
max_drawdown_duration,
win_rate,
profit_factor,
avg_trade_return_pct,
avg_win_pct,
avg_loss_pct,
avg_trade_duration,
total_trades,
winning_trades: stats.winning_trades,
losing_trades: stats.losing_trades,
largest_win: stats.largest_win,
largest_loss: stats.largest_loss,
max_consecutive_wins,
max_consecutive_losses,
calmar_ratio,
total_commission: stats.total_commission,
long_trades: stats.long_trades,
short_trades: stats.short_trades,
total_signals,
executed_signals,
avg_win_duration,
avg_loss_duration,
time_in_market_pct,
max_idle_period,
total_dividend_income: stats.total_dividend_income,
kelly_criterion,
sqn,
expectancy,
omega_ratio,
tail_ratio,
recovery_factor,
ulcer_index,
serenity_ratio,
}
}
}
struct TradeStats {
winning_trades: usize,
losing_trades: usize,
long_trades: usize,
short_trades: usize,
gross_profit: f64,
gross_loss: f64,
total_return_sum: f64,
total_duration: i64,
largest_win: f64,
largest_loss: f64,
total_commission: f64,
total_dividend_income: f64,
winning_returns: Vec<f64>,
losing_returns: Vec<f64>,
all_returns: Vec<f64>,
}
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_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_dividend_income += t.dividend_income;
stats.all_returns.push(t.return_pct);
}
stats
}
fn calculate_kelly(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
let abs_loss = avg_loss_pct.abs();
if abs_loss == 0.0 {
return if avg_win_pct > 0.0 { f64::MAX } else { 0.0 };
}
if avg_win_pct == 0.0 {
return 0.0;
}
let r = avg_win_pct / abs_loss;
win_rate - (1.0 - win_rate) / r
}
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()
}
fn calculate_omega_ratio(returns: &[f64]) -> f64 {
let gains: f64 = returns.iter().map(|&r| r.max(0.0)).sum();
let losses: f64 = returns.iter().map(|&r| (-r).max(0.0)).sum();
if losses == 0.0 {
if gains > 0.0 { f64::MAX } else { 0.0 }
} else {
gains / losses
}
}
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
}
}
fn calculate_ulcer_index(equity_curve: &[EquityPoint]) -> f64 {
if equity_curve.is_empty() {
return 0.0;
}
let sum_sq: f64 = equity_curve
.iter()
.map(|p| (p.drawdown_pct * 100.0).powi(2))
.sum();
(sum_sq / equity_curve.len() as f64).sqrt()
}
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)
}
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)
}
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
}
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)
}
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)
}
fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
let total_duration_secs: i64 = trades.iter().map(|t| t.duration_secs()).sum();
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)
}
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)
}
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)
}
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
}
fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BenchmarkMetrics {
pub symbol: String,
pub benchmark_return_pct: f64,
pub buy_and_hold_return_pct: f64,
pub alpha: f64,
pub beta: f64,
pub information_ratio: f64,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestResult {
pub symbol: String,
pub strategy_name: String,
pub config: BacktestConfig,
pub start_timestamp: i64,
pub end_timestamp: i64,
pub initial_capital: f64,
pub final_equity: f64,
pub metrics: PerformanceMetrics,
pub trades: Vec<Trade>,
pub equity_curve: Vec<EquityPoint>,
pub signals: Vec<SignalRecord>,
pub open_position: Option<Position>,
pub benchmark: Option<BenchmarkMetrics>,
#[serde(default)]
pub diagnostics: Vec<String>,
}
impl BacktestResult {
pub fn summary(&self) -> String {
format!(
"Backtest: {} on {}\n\
Period: {} bars\n\
Initial: ${:.2} -> Final: ${:.2}\n\
Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
self.strategy_name,
self.symbol,
self.equity_curve.len(),
self.initial_capital,
self.final_equity,
self.metrics.total_return_pct,
self.metrics.sharpe_ratio,
self.metrics.max_drawdown_pct * 100.0,
self.metrics.total_trades,
self.metrics.win_rate * 100.0,
self.metrics.profit_factor,
)
}
pub fn is_profitable(&self) -> bool {
self.final_equity > self.initial_capital
}
pub fn total_pnl(&self) -> f64 {
self.final_equity - self.initial_capital
}
pub fn num_bars(&self) -> usize {
self.equity_curve.len()
}
pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
if window == 0 {
return vec![];
}
let returns = calculate_periodic_returns(&self.equity_curve);
if returns.len() < window {
return vec![];
}
let rf = self.config.risk_free_rate;
let bpy = self.config.bars_per_year;
returns
.windows(window)
.map(|w| {
let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
sharpe
})
.collect()
}
pub fn drawdown_series(&self) -> Vec<f64> {
self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
}
pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
if window == 0 || self.trades.len() < window {
return vec![];
}
self.trades
.windows(window)
.map(|w| {
let wins = w.iter().filter(|t| t.is_profitable()).count();
wins as f64 / window as f64
})
.collect()
}
pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
}
pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
}
pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
for trade in &self.trades {
if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
{
trade_groups.entry(day).or_default().push(trade);
}
}
let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
for p in &self.equity_curve {
if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
equity_groups.entry(day).or_default().push(p.clone());
}
}
trade_groups
.into_iter()
.map(|(day, group_trades)| {
let equity_slice = equity_groups.remove(&day).unwrap_or_default();
let initial_capital = equity_slice
.first()
.map(|p| p.equity)
.unwrap_or(self.initial_capital);
let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
let metrics = PerformanceMetrics::calculate(
&trades_vec,
&equity_slice,
initial_capital,
0,
0,
self.config.risk_free_rate,
bpy,
);
let slice_len = equity_slice.len();
(day, partial_period_adjust(metrics, slice_len, bpy))
})
.collect()
}
fn temporal_metrics<K>(
&self,
key_fn: impl Fn(i64) -> Option<K>,
) -> HashMap<K, PerformanceMetrics>
where
K: std::hash::Hash + Eq + Copy,
{
let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
for trade in &self.trades {
if let Some(key) = key_fn(trade.exit_timestamp) {
trade_groups.entry(key).or_default().push(trade);
}
}
let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
for p in &self.equity_curve {
if let Some(key) = key_fn(p.timestamp) {
equity_groups.entry(key).or_default().push(p.clone());
}
}
trade_groups
.into_iter()
.map(|(key, group_trades)| {
let equity_slice = equity_groups.remove(&key).unwrap_or_default();
let initial_capital = equity_slice
.first()
.map(|p| p.equity)
.unwrap_or(self.initial_capital);
let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
let metrics = PerformanceMetrics::calculate(
&trades_vec,
&equity_slice,
initial_capital,
0,
0,
self.config.risk_free_rate,
self.config.bars_per_year,
);
let slice_len = equity_slice.len();
(
key,
partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
)
})
.collect()
}
pub fn trades_by_tag(&self, tag: &str) -> Vec<&Trade> {
self.trades
.iter()
.filter(|t| t.tags.iter().any(|t2| t2 == tag))
.collect()
}
pub fn metrics_by_tag(&self, tag: &str) -> PerformanceMetrics {
let mut equity = self.initial_capital;
let mut peak = equity;
let mut trades_vec: Vec<Trade> = Vec::new();
let mut equity_curve: Vec<EquityPoint> = Vec::new();
for trade in &self.trades {
if !trade.tags.iter().any(|t| t == tag) {
continue;
}
if equity_curve.is_empty() {
equity_curve.push(EquityPoint {
timestamp: trade.entry_timestamp,
equity,
drawdown_pct: 0.0,
});
}
equity += trade.pnl;
if equity > peak {
peak = equity;
}
let drawdown_pct = if peak > 0.0 {
(peak - equity) / peak
} else {
0.0
};
equity_curve.push(EquityPoint {
timestamp: trade.exit_timestamp,
equity,
drawdown_pct,
});
trades_vec.push(trade.clone());
}
if trades_vec.is_empty() {
return PerformanceMetrics::empty(self.initial_capital, &[], 0, 0);
}
let bpy = infer_bars_per_year(&equity_curve, self.config.bars_per_year);
let metrics = PerformanceMetrics::calculate(
&trades_vec,
&equity_curve,
self.initial_capital,
0,
0,
self.config.risk_free_rate,
bpy,
);
partial_period_adjust(metrics, equity_curve.len(), bpy)
}
pub fn all_tags(&self) -> Vec<&str> {
let mut tags: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for trade in &self.trades {
for tag in &trade.tags {
tags.insert(tag.as_str());
}
}
tags.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtesting::position::PositionSide;
use crate::backtesting::signal::Signal;
fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
Trade {
side: if is_long {
PositionSide::Long
} else {
PositionSide::Short
},
entry_timestamp: 0,
exit_timestamp: 100,
entry_price: 100.0,
exit_price: 100.0 + pnl / 10.0,
quantity: 10.0,
entry_quantity: 10.0,
commission: 0.0,
transaction_tax: 0.0,
pnl,
return_pct,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
tags: Vec::new(),
is_partial: false,
scale_sequence: 0,
entry_signal: Signal::long(0, 100.0),
exit_signal: Signal::exit(100, 110.0),
}
}
#[test]
fn test_metrics_no_trades() {
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 10000.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 10100.0,
drawdown_pct: 0.0,
},
];
let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
assert_eq!(metrics.total_trades, 0);
assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
}
#[test]
fn test_metrics_with_trades() {
let trades = vec![
make_trade(100.0, 10.0, true), make_trade(-50.0, -5.0, true), make_trade(75.0, 7.5, false), make_trade(25.0, 2.5, true), ];
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 10000.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 10100.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 2,
equity: 10050.0,
drawdown_pct: 0.005,
},
EquityPoint {
timestamp: 3,
equity: 10125.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 4,
equity: 10150.0,
drawdown_pct: 0.0,
},
];
let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
assert_eq!(metrics.total_trades, 4);
assert_eq!(metrics.winning_trades, 3);
assert_eq!(metrics.losing_trades, 1);
assert!((metrics.win_rate - 0.75).abs() < 0.01);
assert_eq!(metrics.long_trades, 3);
assert_eq!(metrics.short_trades, 1);
}
#[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_max_drawdown_percentage_method() {
let trade = make_trade(100.0, 10.0, true);
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 10000.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 9000.0,
drawdown_pct: 0.1,
},
EquityPoint {
timestamp: 2,
equity: 10000.0,
drawdown_pct: 0.0,
},
];
let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
assert!(
(metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
"max_drawdown_pct should be 0.1 (fraction), got {}",
metrics.max_drawdown_pct
);
assert!(
(metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
"max_drawdown_percentage() should be 10.0, got {}",
metrics.max_drawdown_percentage()
);
}
#[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 test_new_metrics_in_calculate() {
let trades = vec![
make_trade(100.0, 10.0, true),
make_trade(200.0, 20.0, true),
make_trade(-50.0, -5.0, true),
];
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 10000.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 10100.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 2,
equity: 10300.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 3,
equity: 10250.0,
drawdown_pct: 0.005,
},
];
let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
assert!(
m.kelly_criterion > 0.0,
"Kelly should be positive for profitable strategy"
);
assert!(m.sqn.is_finite(), "SQN should be finite");
assert!(
m.expectancy > 0.0,
"Expectancy should be positive in dollar terms"
);
assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
assert!(m.ulcer_index >= 0.0);
assert!(m.recovery_factor > 0.0);
}
#[test]
fn test_profit_factor_all_wins_is_f64_max() {
let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
let equity = vec![
EquityPoint {
timestamp: 0,
equity: 10000.0,
drawdown_pct: 0.0,
},
EquityPoint {
timestamp: 1,
equity: 10150.0,
drawdown_pct: 0.0,
},
];
let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
assert_eq!(metrics.profit_factor, f64::MAX);
}
use super::super::config::BacktestConfig;
use crate::backtesting::position::Position;
use chrono::{NaiveDate, Weekday};
fn make_trade_timed(pnl: f64, return_pct: f64, entry_ts: i64, exit_ts: i64) -> Trade {
Trade {
side: PositionSide::Long,
entry_timestamp: entry_ts,
exit_timestamp: exit_ts,
entry_price: 100.0,
exit_price: 100.0 + pnl / 10.0,
quantity: 10.0,
entry_quantity: 10.0,
commission: 0.0,
transaction_tax: 0.0,
pnl,
return_pct,
dividend_income: 0.0,
unreinvested_dividends: 0.0,
tags: Vec::new(),
is_partial: false,
scale_sequence: 0,
entry_signal: Signal::long(entry_ts, 100.0),
exit_signal: Signal::exit(exit_ts, 100.0 + pnl / 10.0),
}
}
fn make_result(trades: Vec<Trade>, equity_curve: Vec<EquityPoint>) -> BacktestResult {
let metrics = PerformanceMetrics::calculate(
&trades,
&equity_curve,
10000.0,
trades.len(),
trades.len(),
0.0,
252.0,
);
BacktestResult {
symbol: "TEST".to_string(),
strategy_name: "TestStrategy".to_string(),
config: BacktestConfig::default(),
start_timestamp: equity_curve.first().map(|e| e.timestamp).unwrap_or(0),
end_timestamp: equity_curve.last().map(|e| e.timestamp).unwrap_or(0),
initial_capital: 10000.0,
final_equity: equity_curve.last().map(|e| e.equity).unwrap_or(10000.0),
metrics,
trades,
equity_curve,
signals: vec![],
open_position: None::<Position>,
benchmark: None,
diagnostics: vec![],
}
}
fn ts(date: &str) -> i64 {
let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
d.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp()
}
fn equity_point(timestamp: i64, equity: f64, drawdown_pct: f64) -> EquityPoint {
EquityPoint {
timestamp,
equity,
drawdown_pct,
}
}
#[test]
fn rolling_sharpe_window_zero_returns_empty() {
let result = make_result(
vec![],
vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
);
assert!(result.rolling_sharpe(0).is_empty());
}
#[test]
fn rolling_sharpe_insufficient_bars_returns_empty() {
let result = make_result(
vec![],
vec![
equity_point(0, 10000.0, 0.0),
equity_point(1, 10100.0, 0.0),
equity_point(2, 10200.0, 0.0),
],
);
assert!(result.rolling_sharpe(3).is_empty());
}
#[test]
fn rolling_sharpe_correct_length() {
let pts: Vec<EquityPoint> = (0..5)
.map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
.collect();
let result = make_result(vec![], pts);
assert_eq!(result.rolling_sharpe(2).len(), 3);
}
#[test]
fn rolling_sharpe_monotone_increase_positive() {
let pts: Vec<EquityPoint> = (0..10)
.map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
.collect();
let result = make_result(vec![], pts);
let sharpes = result.rolling_sharpe(3);
assert!(!sharpes.is_empty());
for s in &sharpes {
assert!(
*s > 0.0 || *s == f64::MAX,
"expected positive Sharpe, got {s}"
);
}
}
#[test]
fn drawdown_series_mirrors_equity_curve() {
let pts = vec![
equity_point(0, 10000.0, 0.00),
equity_point(1, 9500.0, 0.05),
equity_point(2, 9000.0, 0.10),
equity_point(3, 9200.0, 0.08),
equity_point(4, 10000.0, 0.00),
];
let result = make_result(vec![], pts.clone());
let dd = result.drawdown_series();
assert_eq!(dd.len(), pts.len());
for (got, ep) in dd.iter().zip(pts.iter()) {
assert!(
(got - ep.drawdown_pct).abs() < f64::EPSILON,
"expected {}, got {}",
ep.drawdown_pct,
got
);
}
}
#[test]
fn drawdown_series_empty_curve() {
let result = make_result(vec![], vec![]);
assert!(result.drawdown_series().is_empty());
}
#[test]
fn rolling_win_rate_window_zero_returns_empty() {
let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
assert!(result.rolling_win_rate(0).is_empty());
}
#[test]
fn rolling_win_rate_window_exceeds_trades_returns_empty() {
let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
assert!(result.rolling_win_rate(2).is_empty());
}
#[test]
fn rolling_win_rate_all_wins() {
let trades = vec![
make_trade(10.0, 1.0, true),
make_trade(20.0, 2.0, true),
make_trade(15.0, 1.5, true),
];
let result = make_result(trades, vec![]);
let wr = result.rolling_win_rate(2);
assert_eq!(wr, vec![1.0, 1.0]);
}
#[test]
fn rolling_win_rate_alternating() {
let trades = vec![
make_trade(10.0, 1.0, true),
make_trade(-10.0, -1.0, true),
make_trade(10.0, 1.0, true),
make_trade(-10.0, -1.0, true),
];
let result = make_result(trades, vec![]);
let wr = result.rolling_win_rate(2);
assert_eq!(wr.len(), 3);
for v in &wr {
assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
}
}
#[test]
fn rolling_win_rate_correct_length() {
let trades: Vec<Trade> = (0..5)
.map(|i| make_trade(i as f64, i as f64, true))
.collect();
let result = make_result(trades, vec![]);
assert_eq!(result.rolling_win_rate(3).len(), 3);
}
#[test]
fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
let trades = vec![
make_trade(10.0, 1.0, true),
make_trade(-5.0, -0.5, true),
make_trade(8.0, 0.8, true),
];
let result = make_result(trades, vec![]);
let wr = result.rolling_win_rate(3);
assert_eq!(wr.len(), 1);
assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
}
#[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 by_year_no_trades_empty() {
let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
assert!(result.by_year().is_empty());
}
#[test]
fn by_year_splits_across_years() {
let eq = vec![
equity_point(ts("2022-06-15"), 10000.0, 0.0),
equity_point(ts("2022-06-16"), 10100.0, 0.0),
equity_point(ts("2023-06-15"), 10200.0, 0.0),
equity_point(ts("2023-06-16"), 10300.0, 0.0),
];
let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
let result = make_result(vec![t1, t2], eq);
let by_year = result.by_year();
assert_eq!(by_year.len(), 2);
assert!(by_year.contains_key(&2022));
assert!(by_year.contains_key(&2023));
assert_eq!(by_year[&2022].total_trades, 1);
assert_eq!(by_year[&2023].total_trades, 1);
}
#[test]
fn by_year_all_same_year() {
let eq = vec![
equity_point(ts("2023-03-01"), 10000.0, 0.0),
equity_point(ts("2023-06-01"), 10200.0, 0.0),
equity_point(ts("2023-09-01"), 10500.0, 0.0),
];
let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
let result = make_result(vec![t1, t2], eq);
let by_year = result.by_year();
assert_eq!(by_year.len(), 1);
assert!(by_year.contains_key(&2023));
assert_eq!(by_year[&2023].total_trades, 2);
}
#[test]
fn by_month_splits_across_months() {
let eq = vec![
equity_point(ts("2023-03-15"), 10000.0, 0.0),
equity_point(ts("2023-03-16"), 10100.0, 0.0),
equity_point(ts("2023-07-15"), 10200.0, 0.0),
equity_point(ts("2023-07-16"), 10300.0, 0.0),
];
let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
let result = make_result(vec![t1, t2], eq);
let by_month = result.by_month();
assert_eq!(by_month.len(), 2);
assert!(by_month.contains_key(&(2023, 3)));
assert!(by_month.contains_key(&(2023, 7)));
}
#[test]
fn by_month_same_month_different_years_are_separate_keys() {
let eq = vec![
equity_point(ts("2022-06-15"), 10000.0, 0.0),
equity_point(ts("2023-06-15"), 10200.0, 0.0),
];
let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
let result = make_result(vec![t1, t2], eq);
let by_month = result.by_month();
assert_eq!(by_month.len(), 2);
assert!(by_month.contains_key(&(2022, 6)));
assert!(by_month.contains_key(&(2023, 6)));
}
#[test]
fn by_day_of_week_single_day() {
let monday = ts("2023-01-02");
let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
let eq = vec![equity_point(monday, 10000.0, 0.0)];
let result = make_result(vec![t1, t2], eq);
let by_dow = result.by_day_of_week();
assert_eq!(by_dow.len(), 1);
assert!(by_dow.contains_key(&Weekday::Mon));
assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
}
#[test]
fn by_day_of_week_multiple_days() {
let monday = ts("2023-01-02");
let tuesday = ts("2023-01-03");
let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
let eq = vec![
equity_point(monday, 10000.0, 0.0),
equity_point(tuesday, 10100.0, 0.0),
];
let result = make_result(vec![t_mon, t_tue], eq);
let by_dow = result.by_day_of_week();
assert_eq!(by_dow.len(), 2);
assert!(by_dow.contains_key(&Weekday::Mon));
assert!(by_dow.contains_key(&Weekday::Tue));
assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
}
#[test]
fn by_day_of_week_no_trades_empty() {
let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
assert!(result.by_day_of_week().is_empty());
}
#[test]
fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
let base = ts("2023-01-02"); let week_secs = 7 * 86400i64;
let n_weeks = 104usize;
let equity_pts: Vec<EquityPoint> = (0..n_weeks)
.map(|i| {
equity_point(
base + (i as i64) * week_secs,
10000.0 + i as f64 * 10.0,
0.0,
)
})
.collect();
let trade = make_trade_timed(
100.0,
1.0,
base,
base + week_secs, );
let result = make_result(vec![trade], equity_pts.clone());
let by_dow = result.by_day_of_week();
assert!(by_dow.contains_key(&Weekday::Mon));
let s = by_dow[&Weekday::Mon].sharpe_ratio;
assert!(
s.is_finite() || s == f64::MAX,
"Sharpe should be finite, got {s}"
);
}
#[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}");
}
fn make_tagged_trade(pnl: f64, tags: &[&str]) -> Trade {
let entry_signal = tags
.iter()
.fold(Signal::long(0, 100.0), |sig, &t| sig.tag(t));
let exit_price = 100.0 + pnl / 10.0;
let exit_ts = 86400i64;
let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, entry_signal);
pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
}
fn make_tagged_short_trade(pnl: f64, tags: &[&str]) -> Trade {
let entry_signal = tags
.iter()
.fold(Signal::short(0, 100.0), |sig, &t| sig.tag(t));
let exit_price = 100.0 - pnl / 10.0;
let exit_ts = 86400i64;
let pos = Position::new(PositionSide::Short, 0, 100.0, 10.0, 0.0, entry_signal);
pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
}
#[test]
fn signal_tag_builder_appends_tag() {
let sig = Signal::long(0, 100.0).tag("breakout");
assert_eq!(sig.tags, vec!["breakout"]);
}
#[test]
fn signal_tag_builder_chains_multiple_tags() {
let sig = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
assert_eq!(sig.tags, vec!["breakout", "high_volume"]);
}
#[test]
fn signal_tag_builder_preserves_order() {
let sig = Signal::long(0, 100.0).tag("a").tag("b").tag("c");
assert_eq!(sig.tags, vec!["a", "b", "c"]);
}
#[test]
fn signal_constructors_start_with_empty_tags() {
assert!(Signal::long(0, 0.0).tags.is_empty());
assert!(Signal::short(0, 0.0).tags.is_empty());
assert!(Signal::exit(0, 0.0).tags.is_empty());
assert!(Signal::hold().tags.is_empty());
}
#[test]
fn position_close_propagates_entry_signal_tags_to_trade() {
let entry_signal = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
let pos = Position::new(
crate::backtesting::position::PositionSide::Long,
0,
100.0,
10.0,
0.0,
entry_signal,
);
let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
assert_eq!(trade.tags, vec!["breakout", "high_volume"]);
}
#[test]
fn position_close_propagates_empty_tags_when_none_set() {
let entry_signal = Signal::long(0, 100.0);
let pos = Position::new(
crate::backtesting::position::PositionSide::Long,
0,
100.0,
10.0,
0.0,
entry_signal,
);
let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
assert!(trade.tags.is_empty());
}
#[test]
fn trades_by_tag_returns_matching_trades() {
let result = make_result(
vec![
make_tagged_trade(100.0, &["breakout"]),
make_tagged_trade(-50.0, &["reversal"]),
make_tagged_trade(200.0, &["breakout", "high_volume"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let tagged = result.trades_by_tag("breakout");
assert_eq!(tagged.len(), 2);
assert!((tagged[0].pnl - 100.0).abs() < 1e-9);
assert!((tagged[1].pnl - 200.0).abs() < 1e-9);
}
#[test]
fn trades_by_tag_returns_empty_for_missing_tag() {
let result = make_result(
vec![make_tagged_trade(100.0, &["breakout"])],
vec![equity_point(0, 10000.0, 0.0)],
);
assert!(result.trades_by_tag("nonexistent").is_empty());
}
#[test]
fn trades_by_tag_returns_empty_when_no_trades_tagged() {
let result = make_result(
vec![make_trade(100.0, 10.0, true)],
vec![equity_point(0, 10000.0, 0.0)],
);
assert!(result.trades_by_tag("breakout").is_empty());
}
#[test]
fn trades_by_tag_multi_tag_trade_matches_each_tag() {
let result = make_result(
vec![make_tagged_trade(100.0, &["a", "b", "c"])],
vec![equity_point(0, 10000.0, 0.0)],
);
assert_eq!(result.trades_by_tag("a").len(), 1);
assert_eq!(result.trades_by_tag("b").len(), 1);
assert_eq!(result.trades_by_tag("c").len(), 1);
assert_eq!(result.trades_by_tag("d").len(), 0);
}
#[test]
fn all_tags_returns_sorted_deduped_tags() {
let result = make_result(
vec![
make_tagged_trade(10.0, &["z_tag", "a_tag"]),
make_tagged_trade(10.0, &["m_tag", "a_tag"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let tags = result.all_tags();
assert_eq!(tags, vec!["a_tag", "m_tag", "z_tag"]);
}
#[test]
fn all_tags_returns_empty_when_no_tagged_trades() {
let result = make_result(
vec![make_trade(100.0, 10.0, true)],
vec![equity_point(0, 10000.0, 0.0)],
);
assert!(result.all_tags().is_empty());
}
#[test]
fn all_tags_returns_empty_when_no_trades() {
let result = make_result(vec![], vec![equity_point(0, 10000.0, 0.0)]);
assert!(result.all_tags().is_empty());
}
#[test]
fn metrics_by_tag_returns_empty_metrics_for_missing_tag() {
let result = make_result(
vec![make_tagged_trade(100.0, &["breakout"])],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("nonexistent");
assert_eq!(metrics.total_trades, 0);
assert_eq!(metrics.win_rate, 0.0);
}
#[test]
fn metrics_by_tag_counts_only_tagged_trades() {
let result = make_result(
vec![
make_tagged_trade(100.0, &["breakout"]),
make_tagged_trade(200.0, &["breakout"]),
make_tagged_trade(-50.0, &["reversal"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("breakout");
assert_eq!(metrics.total_trades, 2);
assert_eq!(metrics.long_trades, 2);
}
#[test]
fn metrics_by_tag_win_rate_all_profitable() {
let result = make_result(
vec![
make_tagged_trade(100.0, &["win"]),
make_tagged_trade(200.0, &["win"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("win");
assert!(
(metrics.win_rate - 1.0).abs() < 1e-9,
"expected 100% win rate"
);
}
#[test]
fn metrics_by_tag_win_rate_half_profitable() {
let result = make_result(
vec![
make_tagged_trade(100.0, &["mixed"]),
make_tagged_trade(-100.0, &["mixed"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("mixed");
assert!(
(metrics.win_rate - 0.5).abs() < 1e-9,
"expected 50% win rate, got {}",
metrics.win_rate
);
}
#[test]
fn metrics_by_tag_total_return_reflects_tagged_pnl() {
let result = make_result(
vec![
make_tagged_trade(100.0, &["breakout"]),
make_tagged_trade(200.0, &["breakout"]),
make_tagged_trade(-500.0, &["other"]),
],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("breakout");
assert!(
(metrics.total_return_pct - 3.0).abs() < 0.01,
"expected 3%, got {}",
metrics.total_return_pct
);
}
#[test]
fn metrics_by_tag_mixed_long_short_counts_correctly() {
let long_trade = make_tagged_trade(100.0, &["strategy"]);
let short_trade = make_tagged_short_trade(50.0, &["strategy"]);
assert!(long_trade.is_long());
assert!(short_trade.is_short());
let result = make_result(
vec![long_trade, short_trade],
vec![equity_point(0, 10000.0, 0.0)],
);
let metrics = result.metrics_by_tag("strategy");
assert_eq!(metrics.total_trades, 2);
assert_eq!(metrics.long_trades, 1);
assert_eq!(metrics.short_trades, 1);
assert!(
(metrics.win_rate - 1.0).abs() < 1e-9,
"both trades are profitable"
);
}
#[test]
fn all_tags_deduplicates_within_single_trade() {
let sig = Signal::long(0, 100.0).tag("dup").tag("dup");
let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, sig);
let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
assert_eq!(trade.tags, vec!["dup", "dup"]); let result = make_result(vec![trade], vec![equity_point(0, 10000.0, 0.0)]);
assert_eq!(result.all_tags(), vec!["dup"]); }
#[test]
fn trades_by_tag_is_case_sensitive() {
let result = make_result(
vec![make_tagged_trade(100.0, &["Breakout"])],
vec![equity_point(0, 10000.0, 0.0)],
);
assert_eq!(result.trades_by_tag("Breakout").len(), 1);
assert_eq!(result.trades_by_tag("breakout").len(), 0);
assert_eq!(result.trades_by_tag("BREAKOUT").len(), 0);
}
}