4d0d4cbfa3
* feat: add session tracking and multi-leg spread backtesting Add SessionTracker for trading session management: - Market hours detection (pre-open, trading, squareoff, post-close) - Session boundary tracking with configurable timezone - Squareoff time support for intraday strategies - Session high/low/open price tracking Add SpreadBacktest for multi-leg options strategies: - Support for straddles, strangles, vertical spreads, iron condors - Coordinated entry/exit across all legs - Net premium P&L calculation with max loss/target profit exits - Helper functions for common spread configurations Extend StreamingMetrics for backtest integration: - Add equity and drawdown tracking (update_equity, current_drawdown_pct) - Add trade recording (record_trade, record_fees) - Add finalize() method to produce BacktestMetrics - Add with_initial_capital() constructor Bump version to 0.2.0. * chore: bump up version to 0.2.0 * feat: update version to 0.2.1 and add rolling min/max indicators * fix: formatting
731 lines
22 KiB
Rust
731 lines
22 KiB
Rust
//! Streaming metrics calculation using Welford's algorithm.
|
|
//!
|
|
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
|
|
|
|
use crate::core::types::BacktestMetrics;
|
|
|
|
/// Streaming metrics calculator using Welford's algorithm.
|
|
///
|
|
/// Allows incremental calculation of statistics without storing all values.
|
|
/// Also tracks equity and drawdown for backtesting.
|
|
#[derive(Debug, Clone)]
|
|
pub struct StreamingMetrics {
|
|
/// Number of observations.
|
|
count: usize,
|
|
/// Running mean.
|
|
mean: f64,
|
|
/// Running M2 for variance calculation.
|
|
m2: f64,
|
|
/// Running M2 for downside variance (Sortino).
|
|
m2_downside: f64,
|
|
/// Target return for Sortino (default: 0).
|
|
target_return: f64,
|
|
/// Sum of returns (for total return calculation).
|
|
sum: f64,
|
|
/// Sum of positive returns.
|
|
sum_positive: f64,
|
|
/// Sum of negative returns.
|
|
sum_negative: f64,
|
|
/// Count of positive returns.
|
|
count_positive: usize,
|
|
/// Count of negative returns.
|
|
count_negative: usize,
|
|
|
|
// === Equity and drawdown tracking ===
|
|
/// Initial capital.
|
|
#[allow(dead_code)]
|
|
initial_capital: f64,
|
|
/// Peak equity value (for drawdown calculation).
|
|
peak_equity: f64,
|
|
/// Current equity value.
|
|
current_equity: f64,
|
|
/// Maximum drawdown percentage.
|
|
max_drawdown_pct: f64,
|
|
/// Current drawdown percentage.
|
|
current_drawdown: f64,
|
|
/// Bars since peak (for max drawdown duration).
|
|
bars_since_peak: usize,
|
|
/// Maximum drawdown duration in bars.
|
|
max_drawdown_duration: usize,
|
|
|
|
// === Trade tracking ===
|
|
/// Number of trades.
|
|
trade_count: usize,
|
|
/// Number of winning trades.
|
|
winning_trades: usize,
|
|
/// Number of losing trades.
|
|
losing_trades: usize,
|
|
/// Sum of winning trade P&L.
|
|
sum_wins: f64,
|
|
/// Sum of losing trade P&L.
|
|
sum_losses: f64,
|
|
/// Sum of trade return percentages.
|
|
sum_trade_returns: f64,
|
|
/// Sum of squared trade return percentages (for SQN).
|
|
sum_trade_returns_sq: f64,
|
|
/// Best trade return percentage.
|
|
best_trade_pct: f64,
|
|
/// Worst trade return percentage.
|
|
worst_trade_pct: f64,
|
|
/// Sum of winning trade durations.
|
|
sum_winning_duration: usize,
|
|
/// Sum of losing trade durations.
|
|
sum_losing_duration: usize,
|
|
/// Current consecutive wins.
|
|
current_consecutive_wins: usize,
|
|
/// Current consecutive losses.
|
|
current_consecutive_losses: usize,
|
|
/// Maximum consecutive wins.
|
|
max_consecutive_wins: usize,
|
|
/// Maximum consecutive losses.
|
|
max_consecutive_losses: usize,
|
|
/// Total holding period (bars).
|
|
total_holding_period: usize,
|
|
/// Total fees paid.
|
|
total_fees: f64,
|
|
}
|
|
|
|
impl Default for StreamingMetrics {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl StreamingMetrics {
|
|
/// Create a new streaming metrics calculator.
|
|
pub fn new() -> Self {
|
|
Self::with_initial_capital(0.0)
|
|
}
|
|
|
|
/// Create a new streaming metrics calculator with initial capital.
|
|
pub fn with_initial_capital(initial_capital: f64) -> Self {
|
|
Self {
|
|
count: 0,
|
|
mean: 0.0,
|
|
m2: 0.0,
|
|
m2_downside: 0.0,
|
|
target_return: 0.0,
|
|
sum: 0.0,
|
|
sum_positive: 0.0,
|
|
sum_negative: 0.0,
|
|
count_positive: 0,
|
|
count_negative: 0,
|
|
// Equity tracking
|
|
initial_capital,
|
|
peak_equity: initial_capital,
|
|
current_equity: initial_capital,
|
|
max_drawdown_pct: 0.0,
|
|
current_drawdown: 0.0,
|
|
bars_since_peak: 0,
|
|
max_drawdown_duration: 0,
|
|
// Trade tracking
|
|
trade_count: 0,
|
|
winning_trades: 0,
|
|
losing_trades: 0,
|
|
sum_wins: 0.0,
|
|
sum_losses: 0.0,
|
|
sum_trade_returns: 0.0,
|
|
sum_trade_returns_sq: 0.0,
|
|
best_trade_pct: f64::NEG_INFINITY,
|
|
worst_trade_pct: f64::INFINITY,
|
|
sum_winning_duration: 0,
|
|
sum_losing_duration: 0,
|
|
current_consecutive_wins: 0,
|
|
current_consecutive_losses: 0,
|
|
max_consecutive_wins: 0,
|
|
max_consecutive_losses: 0,
|
|
total_holding_period: 0,
|
|
total_fees: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Create with a custom target return for Sortino calculation.
|
|
pub fn with_target_return(mut self, target: f64) -> Self {
|
|
self.target_return = target;
|
|
self
|
|
}
|
|
|
|
/// Update metrics with a new return value.
|
|
///
|
|
/// Uses Welford's online algorithm for numerically stable variance calculation.
|
|
pub fn update(&mut self, return_value: f64) {
|
|
self.count += 1;
|
|
self.sum += return_value;
|
|
|
|
// Track positive/negative
|
|
if return_value > 0.0 {
|
|
self.sum_positive += return_value;
|
|
self.count_positive += 1;
|
|
} else if return_value < 0.0 {
|
|
self.sum_negative += return_value;
|
|
self.count_negative += 1;
|
|
}
|
|
|
|
// Welford's algorithm for mean and variance
|
|
let delta = return_value - self.mean;
|
|
self.mean += delta / self.count as f64;
|
|
let delta2 = return_value - self.mean;
|
|
self.m2 += delta * delta2;
|
|
|
|
// Downside variance (for Sortino)
|
|
let downside = (return_value - self.target_return).min(0.0);
|
|
let _delta_down = downside - (self.m2_downside / self.count.max(1) as f64).sqrt();
|
|
self.m2_downside += downside * downside;
|
|
}
|
|
|
|
/// Get the number of observations.
|
|
#[inline]
|
|
pub fn count(&self) -> usize {
|
|
self.count
|
|
}
|
|
|
|
/// Get the running mean.
|
|
#[inline]
|
|
pub fn mean(&self) -> f64 {
|
|
self.mean
|
|
}
|
|
|
|
/// Get the sample variance.
|
|
pub fn variance(&self) -> f64 {
|
|
if self.count < 2 {
|
|
return 0.0;
|
|
}
|
|
self.m2 / (self.count - 1) as f64
|
|
}
|
|
|
|
/// Get the population variance.
|
|
pub fn variance_population(&self) -> f64 {
|
|
if self.count == 0 {
|
|
return 0.0;
|
|
}
|
|
self.m2 / self.count as f64
|
|
}
|
|
|
|
/// Get the sample standard deviation.
|
|
pub fn std_dev(&self) -> f64 {
|
|
self.variance().sqrt()
|
|
}
|
|
|
|
/// Get the downside standard deviation (for Sortino).
|
|
pub fn downside_std_dev(&self) -> f64 {
|
|
if self.count < 2 {
|
|
return 0.0;
|
|
}
|
|
(self.m2_downside / (self.count - 1) as f64).sqrt()
|
|
}
|
|
|
|
/// Calculate Sharpe ratio.
|
|
///
|
|
/// # Arguments
|
|
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
|
|
/// * `risk_free_rate` - Annual risk-free rate (default: 0)
|
|
///
|
|
/// # Returns
|
|
/// Annualized Sharpe ratio
|
|
pub fn sharpe_ratio(&self, periods_per_year: f64) -> f64 {
|
|
self.sharpe_ratio_with_rf(periods_per_year, 0.0)
|
|
}
|
|
|
|
/// Calculate Sharpe ratio with custom risk-free rate.
|
|
pub fn sharpe_ratio_with_rf(&self, periods_per_year: f64, risk_free_rate: f64) -> f64 {
|
|
let std = self.std_dev();
|
|
if std == 0.0 || self.count < 2 {
|
|
return 0.0;
|
|
}
|
|
|
|
let rf_per_period = risk_free_rate / periods_per_year;
|
|
let excess_return = self.mean - rf_per_period;
|
|
let annualized_excess = excess_return * periods_per_year;
|
|
let annualized_std = std * periods_per_year.sqrt();
|
|
|
|
annualized_excess / annualized_std
|
|
}
|
|
|
|
/// Calculate Sortino ratio.
|
|
///
|
|
/// # Arguments
|
|
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
|
|
///
|
|
/// # Returns
|
|
/// Annualized Sortino ratio
|
|
pub fn sortino_ratio(&self, periods_per_year: f64) -> f64 {
|
|
let downside_std = self.downside_std_dev();
|
|
if downside_std == 0.0 || self.count < 2 {
|
|
return if self.mean > 0.0 { f64::INFINITY } else { 0.0 };
|
|
}
|
|
|
|
let excess_return = self.mean - self.target_return;
|
|
let annualized_excess = excess_return * periods_per_year;
|
|
let annualized_downside_std = downside_std * periods_per_year.sqrt();
|
|
|
|
annualized_excess / annualized_downside_std
|
|
}
|
|
|
|
/// Get total return.
|
|
pub fn total_return(&self) -> f64 {
|
|
self.sum
|
|
}
|
|
|
|
/// Get average positive return.
|
|
pub fn avg_positive_return(&self) -> f64 {
|
|
if self.count_positive == 0 {
|
|
return 0.0;
|
|
}
|
|
self.sum_positive / self.count_positive as f64
|
|
}
|
|
|
|
/// Get average negative return.
|
|
pub fn avg_negative_return(&self) -> f64 {
|
|
if self.count_negative == 0 {
|
|
return 0.0;
|
|
}
|
|
self.sum_negative / self.count_negative as f64
|
|
}
|
|
|
|
/// Get win rate (fraction of positive returns).
|
|
pub fn win_rate(&self) -> f64 {
|
|
if self.count == 0 {
|
|
return 0.0;
|
|
}
|
|
self.count_positive as f64 / self.count as f64
|
|
}
|
|
|
|
/// Get profit factor (sum of profits / sum of losses).
|
|
pub fn profit_factor(&self) -> f64 {
|
|
if self.sum_negative == 0.0 {
|
|
return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 };
|
|
}
|
|
self.sum_positive / self.sum_negative.abs()
|
|
}
|
|
|
|
/// Get omega ratio (same as profit factor for return-based calculation).
|
|
/// Omega = (sum of returns above threshold) / |sum of returns below threshold|
|
|
/// With threshold = 0, this equals profit_factor.
|
|
pub fn omega_ratio(&self) -> f64 {
|
|
self.profit_factor()
|
|
}
|
|
|
|
// === Equity tracking methods ===
|
|
|
|
/// Update equity and calculate drawdown.
|
|
pub fn update_equity(&mut self, equity: f64) {
|
|
self.current_equity = equity;
|
|
|
|
if equity > self.peak_equity {
|
|
self.peak_equity = equity;
|
|
self.bars_since_peak = 0;
|
|
} else {
|
|
self.bars_since_peak += 1;
|
|
if self.bars_since_peak > self.max_drawdown_duration {
|
|
self.max_drawdown_duration = self.bars_since_peak;
|
|
}
|
|
}
|
|
|
|
// Calculate current drawdown percentage
|
|
if self.peak_equity > 0.0 {
|
|
self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0;
|
|
if self.current_drawdown > self.max_drawdown_pct {
|
|
self.max_drawdown_pct = self.current_drawdown;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get current drawdown percentage.
|
|
#[inline]
|
|
pub fn current_drawdown_pct(&self) -> f64 {
|
|
self.current_drawdown
|
|
}
|
|
|
|
/// Get maximum drawdown percentage.
|
|
#[inline]
|
|
pub fn max_drawdown_pct(&self) -> f64 {
|
|
self.max_drawdown_pct
|
|
}
|
|
|
|
// === Trade tracking methods ===
|
|
|
|
/// Record a completed trade.
|
|
///
|
|
/// # Arguments
|
|
/// * `pnl` - Trade profit/loss
|
|
/// * `return_pct` - Trade return percentage
|
|
/// * `duration` - Trade duration in bars
|
|
pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) {
|
|
self.trade_count += 1;
|
|
self.sum_trade_returns += return_pct;
|
|
self.sum_trade_returns_sq += return_pct * return_pct;
|
|
self.total_holding_period += duration;
|
|
|
|
// Track best/worst trades
|
|
if return_pct > self.best_trade_pct {
|
|
self.best_trade_pct = return_pct;
|
|
}
|
|
if return_pct < self.worst_trade_pct {
|
|
self.worst_trade_pct = return_pct;
|
|
}
|
|
|
|
if pnl > 0.0 {
|
|
self.winning_trades += 1;
|
|
self.sum_wins += pnl;
|
|
self.sum_winning_duration += duration;
|
|
self.current_consecutive_wins += 1;
|
|
self.current_consecutive_losses = 0;
|
|
if self.current_consecutive_wins > self.max_consecutive_wins {
|
|
self.max_consecutive_wins = self.current_consecutive_wins;
|
|
}
|
|
} else if pnl < 0.0 {
|
|
self.losing_trades += 1;
|
|
self.sum_losses += pnl.abs();
|
|
self.sum_losing_duration += duration;
|
|
self.current_consecutive_losses += 1;
|
|
self.current_consecutive_wins = 0;
|
|
if self.current_consecutive_losses > self.max_consecutive_losses {
|
|
self.max_consecutive_losses = self.current_consecutive_losses;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record fees paid.
|
|
pub fn record_fees(&mut self, fees: f64) {
|
|
self.total_fees += fees;
|
|
}
|
|
|
|
/// Finalize metrics and produce BacktestMetrics.
|
|
///
|
|
/// # Arguments
|
|
/// * `initial_capital` - Starting capital
|
|
/// * `final_value` - Ending portfolio value
|
|
/// * `returns` - Array of period returns for ratio calculations
|
|
pub fn finalize(
|
|
&self,
|
|
initial_capital: f64,
|
|
final_value: f64,
|
|
returns: &[f64],
|
|
) -> BacktestMetrics {
|
|
// Calculate return metrics from the returns array
|
|
let mut return_metrics = StreamingMetrics::new();
|
|
for &r in returns {
|
|
if !r.is_nan() {
|
|
return_metrics.update(r);
|
|
}
|
|
}
|
|
|
|
let total_return_pct = if initial_capital > 0.0 {
|
|
(final_value - initial_capital) / initial_capital * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Calculate trade-based metrics
|
|
let win_rate_pct = if self.trade_count > 0 {
|
|
self.winning_trades as f64 / self.trade_count as f64 * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let profit_factor = if self.sum_losses > 0.0 {
|
|
self.sum_wins / self.sum_losses
|
|
} else if self.sum_wins > 0.0 {
|
|
f64::INFINITY
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_trade_return_pct = if self.trade_count > 0 {
|
|
self.sum_trade_returns / self.trade_count as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_win_pct = if self.winning_trades > 0 {
|
|
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_loss_pct = if self.losing_trades > 0 {
|
|
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_winning_duration = if self.winning_trades > 0 {
|
|
self.sum_winning_duration as f64 / self.winning_trades as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_losing_duration = if self.losing_trades > 0 {
|
|
self.sum_losing_duration as f64 / self.losing_trades as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_holding_period = if self.trade_count > 0 {
|
|
self.total_holding_period as f64 / self.trade_count as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Expectancy: average profit per trade
|
|
let expectancy = if self.trade_count > 0 {
|
|
(self.sum_wins - self.sum_losses) / self.trade_count as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// SQN (System Quality Number)
|
|
let sqn = if self.trade_count > 1 {
|
|
let mean_return = self.sum_trade_returns / self.trade_count as f64;
|
|
let variance =
|
|
(self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return);
|
|
let std_dev = variance.max(0.0).sqrt();
|
|
if std_dev > 0.0 {
|
|
(mean_return / std_dev) * (self.trade_count as f64).sqrt()
|
|
} else {
|
|
0.0
|
|
}
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Sharpe ratio (annualized, assuming 252 trading days)
|
|
let sharpe_ratio = return_metrics.sharpe_ratio(252.0);
|
|
|
|
// Sortino ratio (annualized)
|
|
let sortino_ratio = return_metrics.sortino_ratio(252.0);
|
|
|
|
// Calmar ratio (annualized return / max drawdown)
|
|
let calmar_ratio = if self.max_drawdown_pct > 0.0 {
|
|
total_return_pct / self.max_drawdown_pct
|
|
} else if total_return_pct > 0.0 {
|
|
f64::INFINITY
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Omega ratio
|
|
let omega_ratio = return_metrics.omega_ratio();
|
|
|
|
// Best/worst trade handling (handle edge cases)
|
|
let best_trade_pct =
|
|
if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct };
|
|
let worst_trade_pct =
|
|
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
|
|
|
|
BacktestMetrics {
|
|
total_return_pct,
|
|
sharpe_ratio,
|
|
sortino_ratio,
|
|
calmar_ratio,
|
|
omega_ratio,
|
|
max_drawdown_pct: self.max_drawdown_pct,
|
|
max_drawdown_duration: self.max_drawdown_duration,
|
|
win_rate_pct,
|
|
profit_factor,
|
|
expectancy,
|
|
sqn,
|
|
total_trades: self.trade_count,
|
|
total_closed_trades: self.trade_count,
|
|
total_open_trades: 0,
|
|
open_trade_pnl: 0.0,
|
|
winning_trades: self.winning_trades,
|
|
losing_trades: self.losing_trades,
|
|
start_value: initial_capital,
|
|
end_value: final_value,
|
|
total_fees_paid: self.total_fees,
|
|
best_trade_pct,
|
|
worst_trade_pct,
|
|
avg_trade_return_pct,
|
|
avg_win_pct,
|
|
avg_loss_pct,
|
|
avg_winning_duration,
|
|
avg_losing_duration,
|
|
max_consecutive_wins: self.max_consecutive_wins,
|
|
max_consecutive_losses: self.max_consecutive_losses,
|
|
avg_holding_period,
|
|
exposure_pct: 0.0, // TODO: calculate based on time in market
|
|
}
|
|
}
|
|
|
|
/// Reset all metrics.
|
|
pub fn reset(&mut self) {
|
|
*self = Self::new();
|
|
}
|
|
|
|
/// Merge two streaming metrics (for parallel computation).
|
|
pub fn merge(&mut self, other: &StreamingMetrics) {
|
|
if other.count == 0 {
|
|
return;
|
|
}
|
|
if self.count == 0 {
|
|
*self = other.clone();
|
|
return;
|
|
}
|
|
|
|
let combined_count = self.count + other.count;
|
|
let delta = other.mean - self.mean;
|
|
|
|
// Merge means
|
|
let combined_mean = self.mean + delta * other.count as f64 / combined_count as f64;
|
|
|
|
// Merge M2 (parallel variance)
|
|
let combined_m2 = self.m2
|
|
+ other.m2
|
|
+ delta * delta * self.count as f64 * other.count as f64 / combined_count as f64;
|
|
|
|
// Update state
|
|
self.count = combined_count;
|
|
self.mean = combined_mean;
|
|
self.m2 = combined_m2;
|
|
self.sum += other.sum;
|
|
self.sum_positive += other.sum_positive;
|
|
self.sum_negative += other.sum_negative;
|
|
self.count_positive += other.count_positive;
|
|
self.count_negative += other.count_negative;
|
|
self.m2_downside += other.m2_downside; // Approximation
|
|
}
|
|
}
|
|
|
|
/// Calculate Sharpe ratio from a slice of returns.
|
|
pub fn sharpe_ratio(returns: &[f64], periods_per_year: f64, risk_free_rate: f64) -> f64 {
|
|
let mut metrics = StreamingMetrics::new();
|
|
for &r in returns {
|
|
if !r.is_nan() {
|
|
metrics.update(r);
|
|
}
|
|
}
|
|
metrics.sharpe_ratio_with_rf(periods_per_year, risk_free_rate)
|
|
}
|
|
|
|
/// Calculate Sortino ratio from a slice of returns.
|
|
pub fn sortino_ratio(returns: &[f64], periods_per_year: f64) -> f64 {
|
|
let mut metrics = StreamingMetrics::new();
|
|
for &r in returns {
|
|
if !r.is_nan() {
|
|
metrics.update(r);
|
|
}
|
|
}
|
|
metrics.sortino_ratio(periods_per_year)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_basic_statistics() {
|
|
let mut metrics = StreamingMetrics::new();
|
|
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
|
|
|
for v in &values {
|
|
metrics.update(*v);
|
|
}
|
|
|
|
assert_eq!(metrics.count(), 5);
|
|
assert!((metrics.mean() - 3.0).abs() < 1e-10);
|
|
|
|
// Sample variance of [1,2,3,4,5] = 2.5
|
|
assert!((metrics.variance() - 2.5).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_welford_numerical_stability() {
|
|
let mut metrics = StreamingMetrics::new();
|
|
|
|
// Large values that might cause numerical issues with naive algorithm
|
|
let base = 1e10;
|
|
let values = vec![base + 1.0, base + 2.0, base + 3.0];
|
|
|
|
for v in &values {
|
|
metrics.update(*v);
|
|
}
|
|
|
|
// Mean should be base + 2
|
|
assert!((metrics.mean() - (base + 2.0)).abs() < 1e-5);
|
|
|
|
// Variance should be 1.0 (same as [1, 2, 3])
|
|
assert!((metrics.variance() - 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sharpe_ratio() {
|
|
let mut metrics = StreamingMetrics::new();
|
|
|
|
// Daily returns: 1%, 2%, -1%, 1.5%, 0.5%
|
|
let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005];
|
|
|
|
for r in &returns {
|
|
metrics.update(*r);
|
|
}
|
|
|
|
// Should produce a positive Sharpe ratio
|
|
let sharpe = metrics.sharpe_ratio(252.0);
|
|
assert!(sharpe > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sortino_ratio() {
|
|
let mut metrics = StreamingMetrics::new();
|
|
|
|
// Mix of positive and negative returns
|
|
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
|
|
|
|
for r in &returns {
|
|
metrics.update(*r);
|
|
}
|
|
|
|
// Sortino should be different from Sharpe
|
|
let sharpe = metrics.sharpe_ratio(252.0);
|
|
let sortino = metrics.sortino_ratio(252.0);
|
|
|
|
// With negative returns, Sortino penalizes only downside
|
|
assert!(sortino != sharpe);
|
|
}
|
|
|
|
#[test]
|
|
fn test_win_rate_and_profit_factor() {
|
|
let mut metrics = StreamingMetrics::new();
|
|
|
|
// 3 wins, 2 losses
|
|
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
|
|
|
|
for r in &returns {
|
|
metrics.update(*r);
|
|
}
|
|
|
|
// Win rate should be 60%
|
|
assert!((metrics.win_rate() - 0.6).abs() < 1e-10);
|
|
|
|
// Profit factor = 0.06 / 0.03 = 2.0
|
|
assert!((metrics.profit_factor() - 2.0).abs() < 1e-10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge() {
|
|
let mut m1 = StreamingMetrics::new();
|
|
let mut m2 = StreamingMetrics::new();
|
|
|
|
// Split data between two calculators
|
|
for v in &[1.0, 2.0, 3.0] {
|
|
m1.update(*v);
|
|
}
|
|
for v in &[4.0, 5.0] {
|
|
m2.update(*v);
|
|
}
|
|
|
|
// Merge
|
|
m1.merge(&m2);
|
|
|
|
// Should match single calculator with all data
|
|
let mut combined = StreamingMetrics::new();
|
|
for v in &[1.0, 2.0, 3.0, 4.0, 5.0] {
|
|
combined.update(*v);
|
|
}
|
|
|
|
assert_eq!(m1.count(), combined.count());
|
|
assert!((m1.mean() - combined.mean()).abs() < 1e-10);
|
|
assert!((m1.variance() - combined.variance()).abs() < 1e-10);
|
|
}
|
|
}
|