feat(ml): add compute_epoch_financials helper for DQN/PPO

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 15:50:02 +01:00
parent 8a77a2d700
commit eca76e86d3
2 changed files with 190 additions and 0 deletions

View File

@@ -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<f64>,
action_counts: &[usize; 45],
initial_capital: f64,
) -> EpochFinancials {
if pnl_history.is_empty() {
return EpochFinancials::default();
}
let returns: Vec<f64> = 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::<f64>() / 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<f64> = 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::<f64>()
/ 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<f64> = 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<f64> = 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<f64> = 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);
}
}

View File

@@ -21,6 +21,7 @@
mod config;
mod data_loading;
mod early_stopping;
pub(crate) mod financials;
mod features;
pub mod lr_scheduler;
mod monitoring;