use std::collections::BTreeMap;
use chrono::DateTime;
use optionstratlib::Side;
use optionstratlib::backtesting::{
AdvancedRiskMetrics, BacktestResult, CapitalUtilization, DrawdownAnalysis, DrawdownEvent,
GeneralPerformanceMetrics, OptionsSpecificMetrics, TradeStatistics,
};
use optionstratlib::prelude::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::domain::{EquityPoint, OpenPosition};
use crate::engine::ClosedTrade;
use crate::error::BacktestError;
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
pub const MAX_DRAWDOWN_RATIO_KEY: &str = "max_drawdown_ratio";
pub const MAX_DRAWDOWN_CENTS_KEY: &str = "max_drawdown_cents";
pub const NET_PREMIUM_CENTS_KEY: &str = "net_premium_cents";
pub const REALIZED_PNL_CENTS_KEY: &str = "realized_pnl_cents";
pub const SHORT_LEGS_REALIZED_CENTS_KEY: &str = "short_legs_realized_cents";
pub const LONG_LEGS_REALIZED_CENTS_KEY: &str = "long_legs_realized_cents";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metrics {
pub general_performance: GeneralPerformanceMetrics,
pub options_metrics: OptionsSpecificMetrics,
pub trade_statistics: TradeStatistics,
pub drawdown_analysis: DrawdownAnalysis,
pub capital_utilization: CapitalUtilization,
pub risk_metrics: AdvancedRiskMetrics,
pub custom_metrics: BTreeMap<String, Decimal>,
}
impl Metrics {
#[must_use]
pub fn from_result(result: &BacktestResult) -> Self {
Self {
general_performance: result.general_performance.clone(),
options_metrics: result.options_metrics.clone(),
trade_statistics: result.trade_statistics.clone(),
drawdown_analysis: result.drawdown_analysis.clone(),
capital_utilization: result.capital_utilization.clone(),
risk_metrics: result.risk_metrics.clone().unwrap_or_default(),
custom_metrics: result
.custom_metrics
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect(),
}
}
}
pub fn populate(
result: &mut BacktestResult,
equity_curve: &[EquityPoint],
initial_capital_cents: i64,
trade_log: &[ClosedTrade],
open_at_end: &[OpenPosition],
) -> Result<(), BacktestError> {
let returns = step_returns(equity_curve);
populate_return_metrics(&mut result.general_performance, &returns);
let final_cents = equity_curve
.last()
.map_or(initial_capital_cents, |point| point.equity_cents);
if initial_capital_cents != 0 {
let delta = final_cents
.checked_sub(initial_capital_cents)
.ok_or(BacktestError::ArithmeticOverflow)?;
if let Some(total_return) =
Decimal::from(delta).checked_div(Decimal::from(initial_capital_cents))
{
result.general_performance.total_return = total_return;
}
}
let realised = RealisedStats::from_log(trade_log)?;
realised.populate_win_loss(&mut result.general_performance);
result.trade_statistics = realised.trade_statistics(trade_log);
result.options_metrics = realised.options_metrics();
result.capital_utilization = realised.capital_utilization();
result.drawdown_analysis = build_drawdown_analysis(equity_curve, initial_capital_cents)?;
result.risk_metrics = Some(build_advanced_risk(equity_curve, &returns));
let worst_ratio = worst_drawdown_ratio(equity_curve);
if let Some(ratio) = Decimal::from_f64_retain(worst_ratio) {
result
.custom_metrics
.insert(MAX_DRAWDOWN_RATIO_KEY.to_string(), ratio);
}
let drawdown_cents = max_drawdown_cents(equity_curve, initial_capital_cents)?;
result.custom_metrics.insert(
MAX_DRAWDOWN_CENTS_KEY.to_string(),
Decimal::from(drawdown_cents),
);
result.custom_metrics.insert(
REALIZED_PNL_CENTS_KEY.to_string(),
Decimal::from(realised.total_cents),
);
result.custom_metrics.insert(
SHORT_LEGS_REALIZED_CENTS_KEY.to_string(),
Decimal::from(realised.short_cents),
);
result.custom_metrics.insert(
LONG_LEGS_REALIZED_CENTS_KEY.to_string(),
Decimal::from(realised.long_cents),
);
result.custom_metrics.insert(
NET_PREMIUM_CENTS_KEY.to_string(),
Decimal::from(net_premium_cents(trade_log, open_at_end)?),
);
Ok(())
}
fn populate_return_metrics(general: &mut GeneralPerformanceMetrics, returns: &[f64]) {
let Some(mean) = mean(returns) else {
return;
};
if let Some(stddev) = population_stddev(returns, mean) {
if let Some(stddev_dec) = Decimal::from_f64_retain(stddev)
&& let Ok(volatility) = Positive::new_decimal(stddev_dec)
{
general.volatility = Some(volatility);
}
if let Some(sharpe) = ratio(mean, stddev) {
general.sharpe_ratio = Decimal::from_f64_retain(sharpe);
}
}
if let Some(downside) = downside_deviation(returns) {
if let Some(downside_dec) = Decimal::from_f64_retain(downside)
&& let Ok(downside_pos) = Positive::new_decimal(downside_dec)
{
general.downside_deviation = Some(downside_pos);
}
if let Some(sortino) = ratio(mean, downside) {
general.sortino_ratio = Decimal::from_f64_retain(sortino);
}
}
}
struct RealisedStats {
winners: usize,
losers: usize,
break_even: usize,
gross_profit_cents: i128,
gross_loss_cents: i128,
total_cents: i64,
short_cents: i64,
long_cents: i64,
long_trades: usize,
short_trades: usize,
call_trades: usize,
put_trades: usize,
entry_received_cents: i128,
entry_paid_cents: i128,
}
impl RealisedStats {
fn from_log(trade_log: &[ClosedTrade]) -> Result<Self, BacktestError> {
let mut s = Self {
winners: 0,
losers: 0,
break_even: 0,
gross_profit_cents: 0,
gross_loss_cents: 0,
total_cents: 0,
short_cents: 0,
long_cents: 0,
long_trades: 0,
short_trades: 0,
call_trades: 0,
put_trades: 0,
entry_received_cents: 0,
entry_paid_cents: 0,
};
for trade in trade_log {
let pnl = trade.realized_pnl.value();
let premium = i128::from(trade.entry_premium.value())
.checked_mul(i128::from(trade.quantity.value()))
.and_then(|p| p.checked_mul(i128::from(trade.contract_multiplier)))
.ok_or(BacktestError::ArithmeticOverflow)?;
match pnl.cmp(&0) {
std::cmp::Ordering::Greater => {
s.winners += 1;
s.gross_profit_cents = s
.gross_profit_cents
.checked_add(i128::from(pnl))
.ok_or(BacktestError::ArithmeticOverflow)?;
}
std::cmp::Ordering::Less => {
s.losers += 1;
s.gross_loss_cents = s
.gross_loss_cents
.checked_add(i128::from(pnl))
.ok_or(BacktestError::ArithmeticOverflow)?;
}
std::cmp::Ordering::Equal => s.break_even += 1,
}
s.total_cents = s
.total_cents
.checked_add(pnl)
.ok_or(BacktestError::ArithmeticOverflow)?;
match trade.side {
Side::Short => {
s.short_trades += 1;
s.short_cents = s
.short_cents
.checked_add(pnl)
.ok_or(BacktestError::ArithmeticOverflow)?;
s.entry_received_cents = s
.entry_received_cents
.checked_add(premium)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
Side::Long => {
s.long_trades += 1;
s.long_cents = s
.long_cents
.checked_add(pnl)
.ok_or(BacktestError::ArithmeticOverflow)?;
s.entry_paid_cents = s
.entry_paid_cents
.checked_add(premium)
.ok_or(BacktestError::ArithmeticOverflow)?;
}
}
match trade.contract.style {
optionstratlib::OptionStyle::Call => s.call_trades += 1,
optionstratlib::OptionStyle::Put => s.put_trades += 1,
}
}
Ok(s)
}
const fn number_of_trades(&self) -> usize {
self.winners + self.losers + self.break_even
}
fn populate_win_loss(&self, general: &mut GeneralPerformanceMetrics) {
let n = self.number_of_trades();
if n == 0 {
return;
}
general.win_rate = checked_ratio(Decimal::from(self.winners), Decimal::from(n));
if self.gross_loss_cents != 0 {
general.profit_factor = checked_ratio(
Decimal::from(self.gross_profit_cents),
Decimal::from(self.gross_loss_cents.abs()),
);
}
if self.winners > 0 {
let avg = checked_ratio(
Decimal::from(self.gross_profit_cents),
Decimal::from(self.winners as i128),
);
general.avg_gain = avg.map(cents_to_dollars_dec);
}
if self.losers > 0 {
let avg = checked_ratio(
Decimal::from(self.gross_loss_cents),
Decimal::from(self.losers as i128),
);
general.avg_loss = avg.map(cents_to_dollars_dec);
}
if let (Some(gain), Some(loss)) = (general.avg_gain, general.avg_loss)
&& !loss.is_zero()
{
general.gain_loss_ratio = gain.checked_div(loss.abs());
}
}
fn trade_statistics(&self, trade_log: &[ClosedTrade]) -> TradeStatistics {
let mut pnls_dollars: Vec<Decimal> = trade_log
.iter()
.map(|t| cents_to_dollars(t.realized_pnl.value()))
.collect();
pnls_dollars.sort();
let mut holding_days: Vec<Positive> = trade_log
.iter()
.map(|t| days_positive(t.exit_ts.saturating_sub(t.entry_ts)))
.collect();
holding_days.sort();
TradeStatistics {
number_of_trades: self.number_of_trades(),
winners: self.winners,
losers: self.losers,
break_even: self.break_even,
average_trade_return: mean_decimal(&pnls_dollars).unwrap_or(Decimal::ZERO),
median_trade_return: median_decimal(&pnls_dollars).unwrap_or(Decimal::ZERO),
largest_win: pnls_dollars
.last()
.copied()
.filter(|d| d.is_sign_positive()),
largest_loss: pnls_dollars
.first()
.copied()
.filter(|d| d.is_sign_negative()),
average_holding_period: mean_positive(&holding_days),
median_holding_period: median_positive(&holding_days),
min_holding_period: holding_days.first().copied().unwrap_or(Positive::ZERO),
max_holding_period: holding_days.last().copied().unwrap_or(Positive::ZERO),
long_trades: self.long_trades,
short_trades: self.short_trades,
call_trades: self.call_trades,
put_trades: self.put_trades,
spread_trades: 0,
}
}
fn options_metrics(&self) -> OptionsSpecificMetrics {
let n = self.number_of_trades();
let mut m = OptionsSpecificMetrics::default();
if n == 0 {
return m;
}
let denom = Decimal::from(n);
m.calls_percentage = checked_ratio(Decimal::from(self.call_trades), denom);
m.puts_percentage = checked_ratio(Decimal::from(self.put_trades), denom);
m.long_percentage = checked_ratio(Decimal::from(self.long_trades), denom);
m.short_percentage = checked_ratio(Decimal::from(self.short_trades), denom);
let net = self.entry_received_cents - self.entry_paid_cents;
if net != 0 {
m.return_on_premium =
checked_ratio(Decimal::from(self.total_cents), Decimal::from(net));
}
if self.entry_received_cents > 0 {
m.premium_capture = checked_ratio(
Decimal::from(self.total_cents),
Decimal::from(self.entry_received_cents),
);
}
m
}
fn capital_utilization(&self) -> CapitalUtilization {
let net = self.entry_received_cents - self.entry_paid_cents;
CapitalUtilization {
total_premium_received: cents_to_dollars_i128(self.entry_received_cents),
total_premium_paid: cents_to_dollars_i128(self.entry_paid_cents),
net_premium: cents_to_dollars_i128(net),
..CapitalUtilization::default()
}
}
}
fn net_premium_cents(
trade_log: &[ClosedTrade],
open_at_end: &[OpenPosition],
) -> Result<i64, BacktestError> {
let mut net: i128 = 0;
for trade in trade_log {
let premium = premium_cash_cents(
trade.entry_premium.value(),
trade.quantity.value(),
trade.contract_multiplier,
)?;
net = net_add(net, trade.side, premium)?;
}
let run_multiplier = trade_log.first().map_or(1, |t| t.contract_multiplier);
for leg in open_at_end {
let premium = premium_cash_cents(
leg.entry_premium.value(),
leg.quantity.value(),
run_multiplier,
)?;
net = net_add(net, leg.side, premium)?;
}
i64::try_from(net).map_err(|_| BacktestError::ArithmeticOverflow)
}
fn premium_cash_cents(
entry_premium_cents: u64,
quantity: u32,
contract_multiplier: u32,
) -> Result<i128, BacktestError> {
i128::from(entry_premium_cents)
.checked_mul(i128::from(quantity))
.and_then(|p| p.checked_mul(i128::from(contract_multiplier)))
.ok_or(BacktestError::ArithmeticOverflow)
}
fn net_add(net: i128, side: Side, premium: i128) -> Result<i128, BacktestError> {
match side {
Side::Short => net.checked_add(premium),
Side::Long => net.checked_sub(premium),
}
.ok_or(BacktestError::ArithmeticOverflow)
}
fn build_advanced_risk(equity_curve: &[EquityPoint], returns: &[f64]) -> AdvancedRiskMetrics {
let mut m = AdvancedRiskMetrics {
max_consecutive_losses: max_consecutive_losses(returns),
..AdvancedRiskMetrics::default()
};
if !equity_curve.is_empty() {
let n = equity_curve.len() as f64;
let sum_sq: f64 = equity_curve.iter().map(|p| p.drawdown * p.drawdown).sum();
let sum_abs: f64 = equity_curve.iter().map(|p| p.drawdown.abs()).sum();
let ulcer = (sum_sq / n).sqrt();
let pain = sum_abs / n;
m.ulcer_index = Decimal::from_f64_retain(ulcer);
m.pain_index = Decimal::from_f64_retain(pain);
}
m
}
fn max_consecutive_losses(returns: &[f64]) -> usize {
let mut max = 0usize;
let mut run = 0usize;
for &r in returns {
if r < 0.0 {
run += 1;
if run > max {
max = run;
}
} else {
run = 0;
}
}
max
}
fn build_drawdown_analysis(
equity_curve: &[EquityPoint],
initial_capital_cents: i64,
) -> Result<DrawdownAnalysis, BacktestError> {
let mut analysis = DrawdownAnalysis::default();
let worst_ratio = worst_drawdown_ratio(equity_curve);
if let Some(magnitude) = Decimal::from_f64_retain(-worst_ratio) {
analysis.max_drawdown = magnitude;
analysis.avg_drawdown = magnitude; }
let _cents = max_drawdown_cents(equity_curve, initial_capital_cents)?;
if equity_curve.is_empty() {
return Ok(analysis);
}
let bottom_idx = equity_curve
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.drawdown.total_cmp(&b.drawdown))
.map_or(0, |(idx, _)| idx);
let start_idx = (0..=bottom_idx)
.rev()
.find(|&i| {
equity_curve
.get(i)
.is_some_and(|p| p.drawdown >= -f64::EPSILON)
})
.unwrap_or(0);
let recovery_idx = (bottom_idx + 1..equity_curve.len()).find(|&i| {
equity_curve
.get(i)
.is_some_and(|p| p.drawdown >= -f64::EPSILON)
});
let start_ts = equity_curve.get(start_idx).map_or(0, |p| p.ts_ns);
let bottom_ts = equity_curve.get(bottom_idx).map_or(0, |p| p.ts_ns);
let first_ts = equity_curve.first().map_or(0, |p| p.ts_ns);
let last_ts = equity_curve.last().map_or(0, |p| p.ts_ns);
let duration = days_positive(bottom_ts.saturating_sub(start_ts));
let recovery_duration = recovery_idx.map(|i| {
days_positive(
equity_curve
.get(i)
.map_or(0, |p| p.ts_ns)
.saturating_sub(bottom_ts),
)
});
if worst_ratio < 0.0 {
analysis.drawdowns = vec![DrawdownEvent {
start_date: naive_from_ns(start_ts),
bottom_date: naive_from_ns(bottom_ts),
recovery_date: recovery_idx
.map(|i| naive_from_ns(equity_curve.get(i).map_or(0, |p| p.ts_ns))),
magnitude: analysis.max_drawdown,
duration,
recovery_duration,
}];
analysis.max_drawdown_duration = duration;
analysis.recovery_duration = recovery_duration;
analysis.time_to_max_drawdown = days_positive(start_ts.saturating_sub(first_ts));
analysis.avg_recovery_time = recovery_duration;
}
let mut underwater_ns: i64 = 0;
for pair in equity_curve.windows(2) {
let [prev, curr] = pair else { continue };
if curr.drawdown < 0.0 {
underwater_ns = underwater_ns.saturating_add(curr.ts_ns.saturating_sub(prev.ts_ns));
}
}
analysis.total_underwater_days = days_positive(underwater_ns);
let total_ns = last_ts.saturating_sub(first_ts);
if total_ns > 0
&& let Some(pct) = Decimal::from(underwater_ns).checked_div(Decimal::from(total_ns))
{
analysis.underwater_percentage = pct;
}
Ok(analysis)
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "per-step returns are the documented analytic float exception (docs/05 §4); equity cents cast to f64 for the ratio"
)]
fn step_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
let mut returns = Vec::with_capacity(equity_curve.len());
for pair in equity_curve.windows(2) {
let [prev, curr] = pair else { continue };
let prev_cents = prev.equity_cents;
if prev_cents == 0 {
continue;
}
let ret = (curr.equity_cents as f64 - prev_cents as f64) / prev_cents as f64;
if ret.is_finite() {
returns.push(ret);
}
}
returns
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "the count is cast to f64 to average the analytic-float returns"
)]
fn mean(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
let sum: f64 = values.iter().sum();
let mean = sum / values.len() as f64;
mean.is_finite().then_some(mean)
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "the count is cast to f64 to normalise the analytic-float variance"
)]
fn population_stddev(values: &[f64], mean: f64) -> Option<f64> {
if values.is_empty() {
return None;
}
let variance = values
.iter()
.map(|value| {
let delta = value - mean;
delta * delta
})
.sum::<f64>()
/ values.len() as f64;
let stddev = variance.sqrt();
stddev.is_finite().then_some(stddev)
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "the count is cast to f64 to normalise the analytic-float downside variance"
)]
fn downside_deviation(values: &[f64]) -> Option<f64> {
if values.is_empty() {
return None;
}
let variance = values
.iter()
.map(|&value| {
let down = value.min(0.0);
down * down
})
.sum::<f64>()
/ values.len() as f64;
let dev = variance.sqrt();
dev.is_finite().then_some(dev)
}
#[must_use]
fn ratio(mean: f64, denominator: f64) -> Option<f64> {
if denominator <= 0.0 {
return None;
}
let value = mean / denominator;
value.is_finite().then_some(value)
}
#[must_use]
fn worst_drawdown_ratio(equity_curve: &[EquityPoint]) -> f64 {
equity_curve
.iter()
.map(|point| point.drawdown)
.fold(0.0_f64, f64::min)
}
#[must_use = "the computed drawdown magnitude must be used"]
fn max_drawdown_cents(
equity_curve: &[EquityPoint],
initial_capital_cents: i64,
) -> Result<i64, BacktestError> {
let mut peak = initial_capital_cents;
let mut max_decline: i64 = 0;
for point in equity_curve {
if point.equity_cents > peak {
peak = point.equity_cents;
}
let decline = peak
.checked_sub(point.equity_cents)
.ok_or(BacktestError::ArithmeticOverflow)?;
if decline > max_decline {
max_decline = decline;
}
}
Ok(max_decline)
}
#[must_use]
fn checked_ratio(num: Decimal, den: Decimal) -> Option<Decimal> {
if den.is_zero() {
return None;
}
num.checked_div(den)
}
#[must_use]
fn cents_to_dollars(cents: i64) -> Decimal {
Decimal::from_i128_with_scale(i128::from(cents), 2)
}
#[must_use]
fn cents_to_dollars_i128(cents: i128) -> Decimal {
Decimal::from_i128_with_scale(cents, 2)
}
#[must_use]
fn cents_to_dollars_dec(cents: Decimal) -> Decimal {
cents
.checked_div(Decimal::ONE_HUNDRED)
.unwrap_or(Decimal::ZERO)
}
#[must_use]
fn mean_decimal(values: &[Decimal]) -> Option<Decimal> {
if values.is_empty() {
return None;
}
let sum: Decimal = values.iter().copied().sum();
sum.checked_div(Decimal::from(values.len()))
}
#[must_use]
fn median_decimal(sorted: &[Decimal]) -> Option<Decimal> {
let n = sorted.len();
if n == 0 {
return None;
}
if n % 2 == 1 {
sorted.get(n / 2).copied()
} else {
let (Some(a), Some(b)) = (sorted.get(n / 2 - 1), sorted.get(n / 2)) else {
return None;
};
a.checked_add(*b)?.checked_div(Decimal::from(2))
}
}
#[must_use]
fn mean_positive(values: &[Positive]) -> Positive {
if values.is_empty() {
return Positive::ZERO;
}
let sum: Decimal = values.iter().map(|p| p.to_dec()).sum();
sum.checked_div(Decimal::from(values.len()))
.and_then(|avg| Positive::new_decimal(avg).ok())
.unwrap_or(Positive::ZERO)
}
#[must_use]
fn median_positive(sorted: &[Positive]) -> Positive {
let n = sorted.len();
if n == 0 {
return Positive::ZERO;
}
if n % 2 == 1 {
return sorted.get(n / 2).copied().unwrap_or(Positive::ZERO);
}
let (Some(a), Some(b)) = (sorted.get(n / 2 - 1), sorted.get(n / 2)) else {
return Positive::ZERO;
};
a.to_dec()
.checked_add(b.to_dec())
.and_then(|s| s.checked_div(Decimal::from(2)))
.and_then(|avg| Positive::new_decimal(avg).ok())
.unwrap_or(Positive::ZERO)
}
#[must_use]
fn days_positive(nanos: i64) -> Positive {
if nanos <= 0 {
return Positive::ZERO;
}
Decimal::from(nanos)
.checked_div(Decimal::from(NANOS_PER_DAY))
.and_then(|days| Positive::new_decimal(days).ok())
.unwrap_or(Positive::ZERO)
}
#[must_use]
fn naive_from_ns(ns: i64) -> chrono::NaiveDateTime {
DateTime::from_timestamp_nanos(ns).naive_utc()
}
#[cfg(test)]
mod tests {
use chrono::DateTime;
use optionstratlib::backtesting::{BacktestResult, ExitReason};
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use super::{
LONG_LEGS_REALIZED_CENTS_KEY, MAX_DRAWDOWN_CENTS_KEY, MAX_DRAWDOWN_RATIO_KEY, Metrics,
NET_PREMIUM_CENTS_KEY, REALIZED_PNL_CENTS_KEY, SHORT_LEGS_REALIZED_CENTS_KEY,
max_consecutive_losses, max_drawdown_cents, mean, populate, population_stddev, ratio,
step_returns,
};
use crate::domain::{
Cents, ContractKey, EquityPoint, PositionId, PriceCents, Quantity, TradeId, Underlying,
};
use crate::engine::ClosedTrade;
const TS0: i64 = 1_750_291_200_000_000_000;
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
const TOL: f64 = 1e-9;
fn point(step: u32, equity_cents: i64, drawdown: f64) -> EquityPoint {
EquityPoint::new(
step,
TS0 + i64::from(step) * NANOS_PER_DAY,
equity_cents,
0,
equity_cents,
drawdown,
)
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < TOL
}
fn dec_to_f64(d: Decimal) -> f64 {
d.to_f64().unwrap_or(f64::NAN)
}
fn und() -> Underlying {
let Ok(u) = Underlying::new("SPX") else {
panic!("SPX is valid");
};
u
}
fn qty(n: u32) -> Quantity {
let Ok(q) = Quantity::new(n) else {
panic!("{n} is valid");
};
q
}
fn key(strike: u64, style: OptionStyle) -> ContractKey {
ContractKey {
underlying: und(),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(
TS0 + 30 * NANOS_PER_DAY,
)),
strike: PriceCents::new(strike),
style,
}
}
#[allow(clippy::too_many_arguments)]
fn closed(
position_id: u64,
strike: u64,
style: OptionStyle,
side: Side,
entry: u64,
exit: u64,
realized: i64,
exit_reason: ExitReason,
) -> ClosedTrade {
ClosedTrade {
trade_id: TradeId::new(1),
position_id: PositionId::new(position_id),
contract: key(strike, style),
side,
quantity: qty(1),
contract_multiplier: 100,
entry_premium: PriceCents::new(entry),
exit_price: PriceCents::new(exit),
close_fees: Cents::new(0),
close_slippage: Cents::new(0),
realized_pnl: Cents::new(realized),
entry_ts: TS0,
exit_ts: TS0 + 7 * NANOS_PER_DAY,
exit_reason,
}
}
#[test]
fn test_sharpe_hand_built_series_matches_population_formula() {
let returns = [1.0_f64, -0.5];
let Some(m) = mean(&returns) else {
panic!("mean defined");
};
assert!(approx(m, 0.25));
let Some(sd) = population_stddev(&returns, m) else {
panic!("stddev defined");
};
assert!(approx(sd, 0.75));
let Some(sh) = ratio(m, sd) else {
panic!("Sharpe defined");
};
assert!(approx(sh, 1.0 / 3.0));
}
#[test]
fn test_step_returns_zero_prior_equity_is_omitted() {
let curve = [point(0, 0, 0.0), point(1, 100, 0.0), point(2, 200, 0.0)];
let returns = step_returns(&curve);
assert_eq!(returns.len(), 1);
assert!(matches!(returns.first(), Some(r) if approx(*r, 1.0)));
}
#[test]
fn test_max_consecutive_losses_counts_the_longest_run() {
let returns = [-0.1, -0.2, 0.3, -0.1, -0.1, -0.1, 0.5];
assert_eq!(max_consecutive_losses(&returns), 3);
}
#[test]
fn test_max_drawdown_cents_matches_hand_computed_ledger() {
let curve = [
point(0, 10_000, 0.0),
point(1, 9_500, -0.05),
point(2, 9_000, -0.10),
point(3, 9_800, -0.02),
];
let Ok(decline) = max_drawdown_cents(&curve, 10_000) else {
panic!("checked cents arithmetic succeeds");
};
assert_eq!(decline, 1_000);
}
#[test]
fn test_per_leg_realised_split_reports_short_and_long_separately() {
let curve = [point(0, 1_000_000, 0.0), point(1, 1_000_300, 0.0)];
let log = [
closed(
1,
510_000,
OptionStyle::Call,
Side::Short,
2_000,
1_970,
300,
ExitReason::Expiration,
),
closed(
2,
490_000,
OptionStyle::Put,
Side::Short,
1_800,
1_780,
200,
ExitReason::Expiration,
),
closed(
3,
520_000,
OptionStyle::Call,
Side::Long,
800,
785,
-150,
ExitReason::Expiration,
),
closed(
4,
480_000,
OptionStyle::Put,
Side::Long,
700,
695,
-50,
ExitReason::Expiration,
),
];
let mut result = BacktestResult::default();
let Ok(()) = populate(&mut result, &curve, 1_000_000, &log, &[]) else {
panic!("populate succeeds");
};
assert!(matches!(
result.custom_metrics.get(SHORT_LEGS_REALIZED_CENTS_KEY),
Some(v) if *v == Decimal::from(500)
));
assert!(matches!(
result.custom_metrics.get(LONG_LEGS_REALIZED_CENTS_KEY),
Some(v) if *v == Decimal::from(-200)
));
assert!(matches!(
result.custom_metrics.get(REALIZED_PNL_CENTS_KEY),
Some(v) if *v == Decimal::from(300)
));
let stats = &result.trade_statistics;
assert_eq!(stats.number_of_trades, 4);
assert_eq!(stats.winners, 2);
assert_eq!(stats.losers, 2);
assert_eq!(stats.short_trades, 2);
assert_eq!(stats.long_trades, 2);
assert_eq!(stats.call_trades, 2);
assert_eq!(stats.put_trades, 2);
assert!(matches!(
result.custom_metrics.get(NET_PREMIUM_CENTS_KEY),
Some(v) if *v == Decimal::from(230_000)
));
assert!(matches!(
result.general_performance.win_rate,
Some(w) if approx(dec_to_f64(w), 0.5)
));
}
#[test]
fn test_exit_reason_propagates_from_the_trade_log_into_statistics() {
let curve = [point(0, 1_000_000, 0.0), point(1, 999_500, -0.0005)];
let log = [closed(
1,
510_000,
OptionStyle::Call,
Side::Short,
2_000,
2_500,
-500,
ExitReason::StopLoss,
)];
let mut result = BacktestResult::default();
let Ok(()) = populate(&mut result, &curve, 1_000_000, &log, &[]) else {
panic!("populate succeeds");
};
assert_eq!(result.trade_statistics.losers, 1);
assert_eq!(result.trade_statistics.winners, 0);
assert!(matches!(log.first(), Some(t) if t.exit_reason == ExitReason::StopLoss));
}
#[test]
fn test_drawdown_analysis_records_the_worst_event_dated() {
let curve = [
point(0, 1_000_000, 0.0),
point(1, 990_000, -0.01),
point(2, 1_010_000, 0.0),
];
let mut result = BacktestResult::default();
let Ok(()) = populate(&mut result, &curve, 1_000_000, &[], &[]) else {
panic!("populate succeeds");
};
let dd = &result.drawdown_analysis;
assert!(approx(dec_to_f64(dd.max_drawdown), 0.01));
assert_eq!(dd.drawdowns.len(), 1, "one dated worst event");
let Some(event) = dd.drawdowns.first() else {
panic!("one event");
};
assert!(event.recovery_date.is_some(), "recovery detected");
assert!(
approx(dec_to_f64(event.duration.to_dec()), 1.0),
"1 day peak→trough"
);
}
#[test]
fn test_metrics_projection_bundles_upstream_structs_deterministically() {
let curve = [point(0, 1_000_000, 0.0), point(1, 1_000_300, 0.0)];
let log = [closed(
1,
510_000,
OptionStyle::Call,
Side::Short,
2_000,
1_970,
300,
ExitReason::Expiration,
)];
let mut result = BacktestResult::default();
let Ok(()) = populate(&mut result, &curve, 1_000_000, &log, &[]) else {
panic!("populate succeeds");
};
let metrics = Metrics::from_result(&result);
let keys: Vec<&String> = metrics.custom_metrics.keys().collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted, "custom_metrics keys are in sorted order");
let (Ok(a), Ok(b)) = (
serde_json::to_string(&metrics),
serde_json::to_string(&Metrics::from_result(&result)),
) else {
panic!("Metrics serialises");
};
assert_eq!(a, b, "the projection serialises deterministically");
assert_eq!(metrics.trade_statistics.number_of_trades, 1);
}
#[test]
fn test_populate_is_deterministic_for_same_inputs() {
let curve = [
point(0, 1_000_000, 0.0),
point(1, 1_050_000, 0.0),
point(2, 990_000, -0.057_142_857),
];
let log = [closed(
1,
510_000,
OptionStyle::Call,
Side::Short,
2_000,
1_500,
500,
ExitReason::TargetReached,
)];
let mut a = BacktestResult::default();
let mut b = BacktestResult::default();
let Ok(()) = populate(&mut a, &curve, 1_000_000, &log, &[]) else {
panic!("populate a");
};
let Ok(()) = populate(&mut b, &curve, 1_000_000, &log, &[]) else {
panic!("populate b");
};
let (Ok(ja), Ok(jb)) = (
serde_json::to_string(&Metrics::from_result(&a)),
serde_json::to_string(&Metrics::from_result(&b)),
) else {
panic!("serialise");
};
assert_eq!(ja, jb, "same inputs ⇒ byte-identical metrics projection");
}
#[test]
fn test_populate_empty_curve_and_log_leaves_metrics_at_baseline() {
let mut result = BacktestResult::default();
let Ok(()) = populate(&mut result, &[], 1_000_000, &[], &[]) else {
panic!("populate is no-op-safe on empty inputs");
};
assert!(result.general_performance.sharpe_ratio.is_none());
assert!(result.general_performance.total_return.is_zero());
assert_eq!(result.trade_statistics.number_of_trades, 0);
assert!(matches!(
result.custom_metrics.get(MAX_DRAWDOWN_CENTS_KEY),
Some(v) if v.is_zero()
));
assert!(matches!(
result.custom_metrics.get(MAX_DRAWDOWN_RATIO_KEY),
Some(v) if v.is_zero()
));
}
}