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:
189
crates/ml/src/trainers/dqn/financials.rs
Normal file
189
crates/ml/src/trainers/dqn/financials.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
mod config;
|
||||
mod data_loading;
|
||||
mod early_stopping;
|
||||
pub(crate) mod financials;
|
||||
mod features;
|
||||
pub mod lr_scheduler;
|
||||
mod monitoring;
|
||||
|
||||
Reference in New Issue
Block a user