From eca76e86d3791f2087aa0d153bd2155ef289a0cd Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 3 Mar 2026 15:50:02 +0100 Subject: [PATCH] feat(ml): add compute_epoch_financials helper for DQN/PPO Co-Authored-By: Claude Opus 4.6 --- crates/ml/src/trainers/dqn/financials.rs | 189 +++++++++++++++++++++++ crates/ml/src/trainers/dqn/mod.rs | 1 + 2 files changed, 190 insertions(+) create mode 100644 crates/ml/src/trainers/dqn/financials.rs diff --git a/crates/ml/src/trainers/dqn/financials.rs b/crates/ml/src/trainers/dqn/financials.rs new file mode 100644 index 000000000..4e293d338 --- /dev/null +++ b/crates/ml/src/trainers/dqn/financials.rs @@ -0,0 +1,189 @@ +//! Epoch-level financial metrics computed from the DQN trainer's PnL history +//! and action counts. These are pushed to Prometheus at the end of each epoch. + +use std::collections::VecDeque; + +/// Financial metrics summary for a single training epoch. +#[derive(Debug, Clone, Default)] +pub(crate) struct EpochFinancials { + pub sharpe: f64, + pub sortino: f64, + pub win_rate: f64, + pub max_drawdown: f64, + pub profit_factor: f64, + pub total_return: f64, + pub avg_return: f64, + pub total_trades: usize, + pub buy_pct: f64, + pub sell_pct: f64, + pub hold_pct: f64, +} + +/// Compute financial metrics from the trainer's PnL history and action counts. +/// +/// - `pnl_history`: per-step PnL values accumulated this epoch +/// - `action_counts`: 45-element array (FactoredAction), grouped into BUY/SELL/HOLD +/// - `initial_capital`: starting equity for return calculation (default 100_000) +pub(crate) fn compute_epoch_financials( + pnl_history: &VecDeque, + action_counts: &[usize; 45], + initial_capital: f64, +) -> EpochFinancials { + if pnl_history.is_empty() { + return EpochFinancials::default(); + } + + let returns: Vec = pnl_history.iter().copied().collect(); + let n = returns.len(); + + // Total return + let total_pnl: f64 = returns.iter().sum(); + let total_return = total_pnl / initial_capital; + + // Win rate + let winning = returns.iter().filter(|&&r| r > 0.0).count(); + let win_rate = winning as f64 / n as f64; + + // Average return per trade + let avg_return = total_pnl / n as f64; + + // Sharpe ratio (annualized, 252 trading days) + let mean = total_pnl / n as f64; + let variance: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum::() / n as f64; + let std = variance.sqrt(); + let sharpe = if std > 1e-10 { + (mean / std) * (252.0_f64).sqrt() + } else { + 0.0 + }; + + // Sortino ratio (only downside deviation) + let downside_returns: Vec = returns.iter().filter(|&&r| r < 0.0).copied().collect(); + let sortino = if downside_returns.len() > 1 { + let down_var: f64 = downside_returns.iter().map(|r| r.powi(2)).sum::() + / downside_returns.len() as f64; + let down_std = down_var.sqrt(); + if down_std > 1e-10 { + (mean / down_std) * (252.0_f64).sqrt() + } else { + 0.0 + } + } else { + 0.0 + }; + + // Max drawdown + let mut equity = initial_capital; + let mut peak = equity; + let mut max_dd = 0.0_f64; + for &pnl in &returns { + equity += pnl; + if equity > peak { + peak = equity; + } + let dd = (peak - equity) / peak; + if dd > max_dd { + max_dd = dd; + } + } + + // Profit factor + let gross_profit: f64 = returns.iter().filter(|&&r| r > 0.0).sum(); + let gross_loss: f64 = returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum(); + let profit_factor = if gross_loss > 1e-10 { + gross_profit / gross_loss + } else if gross_profit > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Action distribution: group 45 actions into BUY/SELL/HOLD + // FactoredAction: exposure(5) x order(3) x urgency(3) + // Exposure 0,1 = reduce/close (SELL-like), 2 = hold, 3,4 = add/aggressive (BUY-like) + let total_actions: usize = action_counts.iter().sum(); + let (buy_pct, sell_pct, hold_pct) = if total_actions > 0 { + let mut buy = 0usize; + let mut sell = 0usize; + let mut hold = 0usize; + for (i, &count) in action_counts.iter().enumerate() { + let exposure = i / 9; // 0-4 + match exposure { + 0 | 1 => sell += count, // reduce/close + 2 => hold += count, // neutral + 3 | 4 => buy += count, // add/aggressive + _ => {} + } + } + let t = total_actions as f64; + (buy as f64 / t, sell as f64 / t, hold as f64 / t) + } else { + (0.0, 0.0, 0.0) + }; + + EpochFinancials { + sharpe, + sortino, + win_rate, + max_drawdown: max_dd, + profit_factor, + total_return, + avg_return, + total_trades: n, + buy_pct, + sell_pct, + hold_pct, + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_empty_history() { + let f = compute_epoch_financials(&VecDeque::new(), &[0; 45], 100_000.0); + assert_eq!(f.total_trades, 0); + assert_eq!(f.sharpe, 0.0); + } + + #[test] + fn test_all_winning() { + let pnl: VecDeque = vec![10.0, 20.0, 30.0, 15.0, 25.0].into(); + let f = compute_epoch_financials(&pnl, &[0; 45], 100_000.0); + assert_eq!(f.win_rate, 1.0); + assert_eq!(f.total_trades, 5); + assert!(f.sharpe > 0.0); + assert!(f.max_drawdown < 1e-10); // No drawdown with all wins + assert!(f.profit_factor.is_infinite()); // No losses + } + + #[test] + fn test_mixed_pnl() { + let pnl: VecDeque = vec![100.0, -50.0, 75.0, -25.0, 50.0].into(); + let f = compute_epoch_financials(&pnl, &[0; 45], 100_000.0); + assert_eq!(f.total_trades, 5); + assert!((f.win_rate - 0.6).abs() < 1e-10); + assert!(f.total_return > 0.0); + assert!(f.profit_factor > 1.0); + assert!(f.max_drawdown > 0.0); + assert!(f.sortino > 0.0); + } + + #[test] + fn test_action_distribution() { + let mut actions = [0usize; 45]; + // Exposure 3 (add), order 0, urgency 0 → index 27 + actions[27] = 100; // BUY + // Exposure 0 (reduce), order 0, urgency 0 → index 0 + actions[0] = 50; // SELL + // Exposure 2 (neutral), order 0, urgency 0 → index 18 + actions[18] = 50; // HOLD + let pnl: VecDeque = vec![1.0].into(); + let f = compute_epoch_financials(&pnl, &actions, 100_000.0); + assert!((f.buy_pct - 0.5).abs() < 1e-10); + assert!((f.sell_pct - 0.25).abs() < 1e-10); + assert!((f.hold_pct - 0.25).abs() < 1e-10); + } +} diff --git a/crates/ml/src/trainers/dqn/mod.rs b/crates/ml/src/trainers/dqn/mod.rs index 0b7828343..d42722b81 100644 --- a/crates/ml/src/trainers/dqn/mod.rs +++ b/crates/ml/src/trainers/dqn/mod.rs @@ -21,6 +21,7 @@ mod config; mod data_loading; mod early_stopping; +pub(crate) mod financials; mod features; pub mod lr_scheduler; mod monitoring;