Files
ferro-ta/crates/ferro_ta_core/src/attribution.rs
T
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30

330 lines
11 KiB
Rust

//! Performance attribution and trade analysis — pure Rust, no PyO3.
//!
//! Functions
//! ---------
//! - `trade_stats` — win rate, avg win/loss, profit factor, avg hold
//! - `monthly_contribution` — group bar returns by month index and sum
//! - `signal_attribution` — group bar returns by signal label and sum
//! - `extract_trades` — extract trade pnl and hold durations from positions
use std::collections::HashMap;
// ---------------------------------------------------------------------------
// trade_stats
// ---------------------------------------------------------------------------
/// Compute trade-level statistics from trade PnL and hold durations.
///
/// Returns `(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)`.
///
/// - **win_rate** : fraction of trades with PnL > 0
/// - **avg_win** : mean PnL of winning trades (0 if none)
/// - **avg_loss** : mean PnL of losing trades (negative; 0 if none)
/// - **profit_factor** : gross profit / |gross loss| (inf if no losses)
/// - **avg_hold_bars** : mean hold duration across all trades
///
/// # Panics
/// Panics if `pnl` is empty or `pnl.len() != hold_bars.len()`.
pub fn trade_stats(pnl: &[f64], hold_bars: &[f64]) -> (f64, f64, f64, f64, f64) {
let n = pnl.len();
assert!(n > 0, "pnl must be non-empty");
assert_eq!(n, hold_bars.len(), "pnl and hold_bars must have equal length");
let mut wins: Vec<f64> = Vec::new();
let mut losses: Vec<f64> = Vec::new();
for &v in pnl.iter() {
if v > 0.0 {
wins.push(v);
} else if v < 0.0 {
losses.push(v);
}
}
let win_rate = wins.len() as f64 / n as f64;
let avg_win = if wins.is_empty() {
0.0
} else {
wins.iter().sum::<f64>() / wins.len() as f64
};
let avg_loss = if losses.is_empty() {
0.0
} else {
losses.iter().sum::<f64>() / losses.len() as f64
};
let gross_profit: f64 = wins.iter().sum();
let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum();
let profit_factor = if gross_loss == 0.0 {
f64::INFINITY
} else {
gross_profit / gross_loss
};
let avg_hold = hold_bars.iter().sum::<f64>() / n as f64;
(win_rate, avg_win, avg_loss, profit_factor, avg_hold)
}
// ---------------------------------------------------------------------------
// monthly_contribution
// ---------------------------------------------------------------------------
/// Group per-bar returns by month index and sum each month's contribution.
///
/// Returns `(months, contributions)` where `months` is sorted unique month
/// indices and `contributions` is the corresponding total return per month.
/// NaN returns are skipped.
///
/// # Panics
/// Panics if `bar_returns.len() != month_index.len()`.
pub fn monthly_contribution(bar_returns: &[f64], month_index: &[i64]) -> (Vec<i64>, Vec<f64>) {
let n = bar_returns.len();
assert_eq!(
n,
month_index.len(),
"bar_returns and month_index must have equal length"
);
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !bar_returns[i].is_nan() {
*map.entry(month_index[i]).or_insert(0.0) += bar_returns[i];
}
}
let mut months: Vec<i64> = map.keys().copied().collect();
months.sort_unstable();
let contributions: Vec<f64> = months.iter().map(|m| map[m]).collect();
(months, contributions)
}
// ---------------------------------------------------------------------------
// signal_attribution
// ---------------------------------------------------------------------------
/// Attribute per-bar returns to each signal label.
///
/// Returns `(labels, contributions)` where `labels` is sorted unique signal
/// labels and `contributions` is the corresponding total return per label.
/// NaN returns are skipped.
///
/// # Panics
/// Panics if `bar_returns.len() != signal_labels.len()`.
pub fn signal_attribution(bar_returns: &[f64], signal_labels: &[i64]) -> (Vec<i64>, Vec<f64>) {
let n = bar_returns.len();
assert_eq!(
n,
signal_labels.len(),
"bar_returns and signal_labels must have equal length"
);
let mut map: HashMap<i64, f64> = HashMap::new();
for i in 0..n {
if !bar_returns[i].is_nan() {
*map.entry(signal_labels[i]).or_insert(0.0) += bar_returns[i];
}
}
let mut labels: Vec<i64> = map.keys().copied().collect();
labels.sort_unstable();
let contributions: Vec<f64> = labels.iter().map(|l| map[l]).collect();
(labels, contributions)
}
// ---------------------------------------------------------------------------
// extract_trades
// ---------------------------------------------------------------------------
/// Extract trade-level PnL and hold durations from positions and strategy returns.
///
/// A trade is a maximal contiguous run of non-zero position values with the
/// same sign/magnitude. Returns `(pnl, hold_durations)`.
///
/// # Panics
/// Panics if `positions.len() != strategy_returns.len()`.
pub fn extract_trades(positions: &[f64], strategy_returns: &[f64]) -> (Vec<f64>, Vec<f64>) {
let n = positions.len();
assert_eq!(
n,
strategy_returns.len(),
"positions and strategy_returns must have equal length"
);
let mut pnl = Vec::<f64>::new();
let mut hold = Vec::<f64>::new();
let mut i = 0usize;
while i < n {
if positions[i] == 0.0 {
i += 1;
continue;
}
let mut j = i + 1;
while j < n && positions[j] == positions[i] {
j += 1;
}
let mut trade_pnl = 0.0_f64;
for v in strategy_returns.iter().take(j).skip(i) {
trade_pnl += *v;
}
pnl.push(trade_pnl);
hold.push((j - i) as f64);
i = j;
}
(pnl, hold)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- trade_stats ---------------------------------------------------------
#[test]
fn test_trade_stats_basic() {
let pnl = [100.0, -50.0, 200.0, -30.0, 150.0];
let hold = [5.0, 3.0, 7.0, 2.0, 6.0];
let (wr, aw, al, pf, ah) = trade_stats(&pnl, &hold);
// 3 wins out of 5
assert!((wr - 0.6).abs() < 1e-10);
// avg win = (100+200+150)/3
assert!((aw - 150.0).abs() < 1e-10);
// avg loss = (-50 + -30)/2 = -40
assert!((al - (-40.0)).abs() < 1e-10);
// profit_factor = 450 / 80
assert!((pf - 5.625).abs() < 1e-10);
// avg hold = (5+3+7+2+6)/5 = 4.6
assert!((ah - 4.6).abs() < 1e-10);
}
#[test]
fn test_trade_stats_all_wins() {
let pnl = [10.0, 20.0];
let hold = [1.0, 2.0];
let (wr, _aw, al, pf, _ah) = trade_stats(&pnl, &hold);
assert!((wr - 1.0).abs() < 1e-10);
assert!((al - 0.0).abs() < 1e-10);
assert!(pf.is_infinite());
}
#[test]
fn test_trade_stats_all_losses() {
let pnl = [-10.0, -20.0];
let hold = [1.0, 2.0];
let (wr, aw, _al, pf, _ah) = trade_stats(&pnl, &hold);
assert!((wr - 0.0).abs() < 1e-10);
assert!((aw - 0.0).abs() < 1e-10);
assert!((pf - 0.0).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "pnl must be non-empty")]
fn test_trade_stats_empty() {
trade_stats(&[], &[]);
}
// -- monthly_contribution ------------------------------------------------
#[test]
fn test_monthly_contribution_basic() {
let returns = [0.01, 0.02, -0.01, 0.03, -0.02];
let months = [0, 0, 1, 1, 2];
let (m, c) = monthly_contribution(&returns, &months);
assert_eq!(m, vec![0, 1, 2]);
assert!((c[0] - 0.03).abs() < 1e-10);
assert!((c[1] - 0.02).abs() < 1e-10);
assert!((c[2] - (-0.02)).abs() < 1e-10);
}
#[test]
fn test_monthly_contribution_nan_skipped() {
let returns = [0.01, f64::NAN, 0.03];
let months = [0, 0, 1];
let (m, c) = monthly_contribution(&returns, &months);
assert_eq!(m, vec![0, 1]);
assert!((c[0] - 0.01).abs() < 1e-10);
assert!((c[1] - 0.03).abs() < 1e-10);
}
#[test]
fn test_monthly_contribution_empty() {
let (m, c) = monthly_contribution(&[], &[]);
assert!(m.is_empty());
assert!(c.is_empty());
}
// -- signal_attribution --------------------------------------------------
#[test]
fn test_signal_attribution_basic() {
let returns = [0.05, -0.02, 0.03, 0.01];
let labels = [1, -1, 2, 1];
let (l, c) = signal_attribution(&returns, &labels);
assert_eq!(l, vec![-1, 1, 2]);
assert!((c[0] - (-0.02)).abs() < 1e-10);
assert!((c[1] - 0.06).abs() < 1e-10); // 0.05 + 0.01
assert!((c[2] - 0.03).abs() < 1e-10);
}
#[test]
fn test_signal_attribution_nan_skipped() {
let returns = [0.05, f64::NAN];
let labels = [1, 2];
let (l, c) = signal_attribution(&returns, &labels);
assert_eq!(l, vec![1]);
assert!((c[0] - 0.05).abs() < 1e-10);
}
// -- extract_trades ------------------------------------------------------
#[test]
fn test_extract_trades_basic() {
// positions: flat, long, long, flat, short, short
let positions = [0.0, 1.0, 1.0, 0.0, -1.0, -1.0];
let strat_ret = [0.0, 0.01, 0.02, 0.0, -0.01, 0.03];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert_eq!(pnl.len(), 2);
assert_eq!(hold.len(), 2);
// First trade: bars 1..3 => 0.01 + 0.02 = 0.03
assert!((pnl[0] - 0.03).abs() < 1e-10);
assert!((hold[0] - 2.0).abs() < 1e-10);
// Second trade: bars 4..6 => -0.01 + 0.03 = 0.02
assert!((pnl[1] - 0.02).abs() < 1e-10);
assert!((hold[1] - 2.0).abs() < 1e-10);
}
#[test]
fn test_extract_trades_all_flat() {
let positions = [0.0, 0.0, 0.0];
let strat_ret = [0.01, 0.02, 0.03];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert!(pnl.is_empty());
assert!(hold.is_empty());
}
#[test]
fn test_extract_trades_empty() {
let (pnl, hold) = extract_trades(&[], &[]);
assert!(pnl.is_empty());
assert!(hold.is_empty());
}
#[test]
fn test_extract_trades_single_bar_trade() {
let positions = [0.0, 1.0, 0.0];
let strat_ret = [0.0, 0.05, 0.0];
let (pnl, hold) = extract_trades(&positions, &strat_ret);
assert_eq!(pnl.len(), 1);
assert!((pnl[0] - 0.05).abs() < 1e-10);
assert!((hold[0] - 1.0).abs() < 1e-10);
}
}