fix: update action reporting from 9-level to 7-level ExposureLevel

ShortSmall=0, ShortHalf=1, ShortFull=2, Flat=3, LongSmall=4, LongHalf=5,
LongFull=6. Updates all array sizes, bounds checks, names, and distribution
logic across gpu_monitoring, monitoring, training_loop, metrics, and financials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-11 12:12:18 +02:00
parent 1e133e4ca1
commit 3696cb475b
5 changed files with 91 additions and 88 deletions

View File

@@ -26,8 +26,8 @@ pub struct MonitoringSummary {
pub max_reward: f32,
/// Sharpe estimate: mean_reward / reward_std (per-trade).
pub sharpe_estimate: f32,
/// Per direction*magnitude combo counts (9 bins: 3 dir * 3 mag).
pub action_counts: [usize; 9],
/// Per exposure level counts (7 bins: ShortSmall=0, ShortHalf=1, ShortFull=2, Flat=3, LongSmall=4, LongHalf=5, LongFull=6).
pub action_counts: [usize; 7],
/// Per-order-type counts (3: Market, LimitMaker, IoC).
pub order_counts: [usize; 3],
/// Per-urgency counts (3: Patient, Normal, Aggressive).
@@ -53,7 +53,7 @@ impl GpuMonitoringReducer {
.map_err(|e| MLError::ModelError(format!("monitoring module load: {e}")))?;
let kernel_func = module.load_function("monitoring_reduce")
.map_err(|e| MLError::ModelError(format!("monitoring_reduce load: {e}")))?;
let summary_buf = stream.alloc_zeros::<f32>(24) // 5 stats + 9 exp + 3 ord + 3 urg + 1 total + 3 pad
let summary_buf = stream.alloc_zeros::<f32>(24) // 5 stats + 7 exp + 3 ord + 3 urg + 1 total + 5 pad
.map_err(|e| MLError::ModelError(format!("monitoring summary alloc: {e}")))?;
Ok(Self { stream: Arc::clone(stream), kernel_func, summary_buf })
@@ -81,7 +81,7 @@ impl GpuMonitoringReducer {
};
// Safety: rewards and actions are valid GPU slices with at least n elements.
// summary_buf is pre-allocated 12-float device buffer on the same stream.
let num_actions_i32 = 9_i32; // dir*mag combos (3*3)
let num_actions_i32 = 7_i32; // exposure levels (7: ShortSmall..LongFull)
let order_actions_i32 = 3_i32; // DQN_ORDER_ACTIONS
let urgency_actions_i32 = 3_i32; // DQN_URGENCY_ACTIONS
unsafe {
@@ -113,12 +113,12 @@ impl GpuMonitoringReducer {
action_counts: [
raw[5] as usize, raw[6] as usize, raw[7] as usize,
raw[8] as usize, raw[9] as usize, raw[10] as usize,
raw[11] as usize, raw[12] as usize, raw[13] as usize,
raw[11] as usize,
],
order_counts: [raw[14] as usize, raw[15] as usize, raw[16] as usize],
urgency_counts: [raw[17] as usize, raw[18] as usize, raw[19] as usize],
total_experiences: raw[20] as usize,
total_trades: raw[21] as usize,
order_counts: [raw[12] as usize, raw[13] as usize, raw[14] as usize],
urgency_counts: [raw[15] as usize, raw[16] as usize, raw[17] as usize],
total_experiences: raw[18] as usize,
total_trades: raw[19] as usize,
})
}
}
@@ -131,7 +131,7 @@ mod tests {
fn test_monitoring_summary_default() {
let s = MonitoringSummary::default();
assert_eq!(s.total_experiences, 0);
assert_eq!(s.action_counts, [0; 9]);
assert_eq!(s.action_counts, [0; 7]);
}
#[test]

View File

@@ -22,13 +22,14 @@ pub(crate) struct EpochFinancials {
/// Compute financial metrics from GPU-sourced trade statistics and action counts.
///
/// - `trade_stats`: per-epoch trade statistics from `collector.collect_trade_stats()`
/// - `action_counts`: 9-element array (exposure levels), grouped into BUY/SELL/HOLD
/// - `action_counts`: 7-element array (exposure levels), grouped into BUY/SELL/HOLD
/// Indices: 0=ShortSmall, 1=ShortHalf, 2=ShortFull, 3=Flat, 4=LongSmall, 5=LongHalf, 6=LongFull
/// - `initial_capital`: starting equity for return calculation (default 100_000)
/// - `bars_per_day`: number of bars in one trading day (390 for 1-min, 78 for 5-min, etc.)
/// Used for Sharpe/Sortino annualization and return scaling. Pass from data pipeline's BarSize.
pub(crate) fn compute_epoch_financials(
trade_stats: &TradeStats,
action_counts: &[usize; 9],
action_counts: &[usize; 7],
initial_capital: f64,
bars_per_day: f64,
) -> EpochFinancials {
@@ -164,15 +165,15 @@ pub(crate) fn compute_epoch_financials(
// Cap at 100% — drawdown cannot exceed total liquidation
let max_dd = max_dd.min(1.0);
// Action distribution: 9 exposure actions -> BUY/SELL/HOLD
// [S100, S75, S50, S25, Flat, L25, L50, L75, L100]
// 0 1 2 3 4 5 6 7 8
// Sell = indices 0-3, Hold = index 4, Buy = indices 5-8
// Action distribution: 7 exposure actions -> BUY/SELL/HOLD
// [ShortSmall, ShortHalf, ShortFull, Flat, LongSmall, LongHalf, LongFull]
// 0 1 2 3 4 5 6
// Sell = indices 0-2, Hold = index 3, Buy = indices 4-6
let total_actions: usize = action_counts.iter().sum();
let (buy_pct, sell_pct, hold_pct) = if total_actions > 0 {
let sell: usize = action_counts.iter().take(4).sum(); // S100+S75+S50+S25
let hold = *action_counts.get(4).unwrap_or(&0); // Flat
let buy: usize = action_counts.iter().skip(5).sum(); // L25+L50+L75+L100
let sell: usize = action_counts.iter().take(3).sum(); // ShortSmall+ShortHalf+ShortFull
let hold = *action_counts.get(3).unwrap_or(&0); // Flat
let buy: usize = action_counts.iter().skip(4).sum(); // LongSmall+LongHalf+LongFull
let t = total_actions as f64;
(buy as f64 / t, sell as f64 / t, hold as f64 / t)
} else {
@@ -202,7 +203,7 @@ mod tests {
#[test]
fn test_empty_trade_stats() {
let ts = TradeStats::default();
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
assert_eq!(f.total_trades, 0);
assert_eq!(f.sharpe, 0.0);
}
@@ -220,7 +221,7 @@ mod tests {
step_returns: vec![0.01, 0.02, 0.03, 0.015, 0.025],
..Default::default()
};
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
assert_eq!(f.win_rate, 1.0);
assert_eq!(f.total_trades, 5);
assert!(f.sharpe > 0.0, "sharpe={}", f.sharpe);
@@ -241,7 +242,7 @@ mod tests {
step_returns: vec![0.01, -0.005, 0.008, -0.003, 0.005],
..Default::default()
};
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
assert_eq!(f.total_trades, 5);
assert!((f.win_rate - 0.6).abs() < 1e-10, "win_rate={}", f.win_rate);
assert!(f.total_return > 0.0, "total_return={}", f.total_return);
@@ -251,13 +252,13 @@ mod tests {
}
#[test]
fn test_action_distribution_9_actions() {
let mut actions = [0usize; 9];
actions[0] = 10; // S100 -> SELL
actions[1] = 10; // S75 -> SELL
actions[4] = 20; // Flat -> HOLD
actions[7] = 30; // L75 -> BUY
actions[8] = 30; // L100 -> BUY
fn test_action_distribution_7_actions() {
let mut actions = [0usize; 7];
actions[0] = 10; // ShortSmall -> SELL
actions[1] = 10; // ShortHalf -> SELL
actions[3] = 20; // Flat -> HOLD
actions[5] = 30; // LongHalf -> BUY
actions[6] = 30; // LongFull -> BUY
let ts = TradeStats {
total_trades: 1,
winning_trades: 1,
@@ -278,8 +279,8 @@ mod tests {
#[test]
fn test_action_distribution_all_indices_counted() {
let mut actions = [0usize; 9];
for i in 0..9 { actions[i] = 10; }
let mut actions = [0usize; 7];
for i in 0..7 { actions[i] = 10; }
let ts = TradeStats {
total_trades: 1,
winning_trades: 1,
@@ -323,7 +324,7 @@ mod tests {
step_returns,
done_flags,
};
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
// Episode 1 drawdown: (1-0.8)*(1-0.15)*(1-0.10)*(1-0.05) ≈ 0.4131 → ~42% DD
// Episode 2 starts fresh, drawdown is smaller.
@@ -355,7 +356,7 @@ mod tests {
step_returns: vec![0.01, -0.005, 0.008, -0.003],
done_flags: vec![], // empty — no episode info
};
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
// Should not panic and should produce a valid number
assert!(f.max_drawdown >= 0.0, "max_dd={}", f.max_drawdown);
assert!(f.max_drawdown <= 1.0, "max_dd={}", f.max_drawdown);

View File

@@ -12,14 +12,14 @@ use crate::dqn::action_space::FactoredAction;
pub(crate) struct TrainingMonitor {
pub(crate) epoch: usize,
pub(crate) reward_history: Vec<f32>,
pub(crate) action_counts: [usize; 9], // 9 direction*magnitude bins (3 dir * 3 mag)
pub(crate) q_value_sums: [f64; 9], // Sum of Q-values per dir*mag combo
pub(crate) q_value_counts: [usize; 9], // Count of Q-values per dir*mag combo
pub(crate) action_counts: [usize; 7], // 7 exposure levels (ShortSmall..LongFull)
pub(crate) q_value_sums: [f64; 7], // Sum of Q-values per exposure level
pub(crate) q_value_counts: [usize; 7], // Count of Q-values per exposure level
pub(crate) order_type_counts: [usize; 3], // Market, LimitMaker, IoC
pub(crate) urgency_counts: [usize; 3], // Patient, Normal, Aggressive
/// Factored action counts: 3 dir * 3 mag * 3 order * 3 urgency = 81 actions.
/// Index = dir * 27 + mag * 9 + order * 3 + urgency (0-80).
pub(crate) factored_action_counts: [usize; 81],
/// Factored action counts: 7 exposure * 3 order * 3 urgency = 63 actions.
/// Index = exp_idx * 9 + order * 3 + urgency (0-62).
pub(crate) factored_action_counts: [usize; 63],
pub(crate) consecutive_constant_epochs: usize,
// Q-value range tracking (WAVE 9-11 production monitoring)
pub(crate) q_value_min: f64,
@@ -37,12 +37,12 @@ impl TrainingMonitor {
Self {
epoch,
reward_history: Vec::new(),
action_counts: [0; 9],
q_value_sums: [0.0; 9],
q_value_counts: [0; 9],
action_counts: [0; 7],
q_value_sums: [0.0; 7],
q_value_counts: [0; 7],
order_type_counts: [0; 3],
urgency_counts: [0; 3],
factored_action_counts: [0; 81],
factored_action_counts: [0; 63],
consecutive_constant_epochs: 0,
q_value_min: f64::INFINITY,
q_value_max: f64::NEG_INFINITY,
@@ -66,9 +66,9 @@ impl TrainingMonitor {
}
}
/// Add action to tracking by direction*magnitude index (0-8)
/// Add action to tracking by exposure index (0-6)
pub(crate) fn track_action_by_exposure(&mut self, exposure_idx: usize) {
if exposure_idx < 9 {
if exposure_idx < 7 {
self.action_counts[exposure_idx] += 1;
}
}
@@ -81,9 +81,9 @@ impl TrainingMonitor {
self.order_type_counts[order_idx] += 1;
let urgency_idx = action.urgency as usize; // 0-2
self.urgency_counts[urgency_idx] += 1;
// Track composite factored action index (0-44)
// Track composite factored action index (0-62): exp(0-6) * 9 + order(0-2) * 3 + urgency(0-2)
let factored_idx = exp_idx * 9 + order_idx * 3 + urgency_idx;
if factored_idx < 81 {
if factored_idx < 63 {
self.factored_action_counts[factored_idx] += 1;
}
}
@@ -102,9 +102,9 @@ impl TrainingMonitor {
}
}
/// Add Q-value to tracking by direction*magnitude index (0-8)
/// Add Q-value to tracking by exposure index (0-6)
pub(crate) fn track_q_value_by_exposure(&mut self, exposure_idx: usize, q_value: f64) {
if exposure_idx < 9 {
if exposure_idx < 7 {
self.q_value_sums[exposure_idx] += q_value;
self.q_value_counts[exposure_idx] += 1;
}
@@ -190,7 +190,7 @@ impl TrainingMonitor {
return Ok(());
}
let exposure_names = ["S_25", "S_50", "S_100", "F_25", "F_50", "F_100", "L_25", "L_50", "L_100"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
for (i, &count) in self.action_counts.iter().enumerate() {
let pct = (count as f64 / total_exposure as f64) * 100.0;
if pct < 5.0 {
@@ -220,9 +220,9 @@ impl TrainingMonitor {
/// Validate Q-value balance across exposure actions
pub(crate) fn validate_q_value_balance(&self) -> Result<()> {
// Calculate average Q-value per direction*magnitude combo
let mut avg_q_values = [0.0_f64; 9];
for i in 0..9 {
// Calculate average Q-value per exposure level
let mut avg_q_values = [0.0_f64; 7];
for i in 0..7 {
if self.q_value_counts[i] > 0 {
avg_q_values[i] = self.q_value_sums[i] / self.q_value_counts[i] as f64;
}
@@ -233,7 +233,7 @@ impl TrainingMonitor {
let min_q = avg_q_values.iter().cloned().fold(f64::INFINITY, f64::min);
if (max_q - min_q).abs() > 1000.0 {
let exposure_names = ["S_25", "S_50", "S_100", "F_25", "F_50", "F_100", "L_25", "L_50", "L_100"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
warn!(
"Q-VALUE DIVERGENCE at epoch {}: {}",
self.epoch,
@@ -252,11 +252,11 @@ impl TrainingMonitor {
if self.epoch % 10 == 0 {
let total_actions: usize = self.action_counts.iter().sum();
if total_actions > 0 {
let exposure_names = ["S_25", "S_50", "S_100", "F_25", "F_50", "F_100", "L_25", "L_50", "L_100"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
// Log all 9 dir*mag actions
// Log all 7 exposure level actions
debug!(
"Action Distribution [Epoch {}] - 9 Dir*Mag Actions:",
"Action Distribution [Epoch {}] - 7 Exposure Level Actions:",
self.epoch
);
for (idx, &count) in self.action_counts.iter().enumerate() {
@@ -297,13 +297,13 @@ impl TrainingMonitor {
let unique_ord = self.order_type_counts.iter().filter(|&&c| c > 0).count();
let unique_urg = self.urgency_counts.iter().filter(|&&c| c > 0).count();
debug!(
"Branch Diversity [Epoch {}]: exp={}/9 order={}/3 urgency={}/3",
"Branch Diversity [Epoch {}]: exp={}/7 order={}/3 urgency={}/3",
self.epoch, unique_exp, unique_ord, unique_urg
);
// Log average Q-values per dir*mag combo
// Log average Q-values per exposure level
debug!("Average Q-values [Epoch {}]:", self.epoch);
for idx in 0..9 {
for idx in 0..7 {
if self.q_value_counts[idx] > 0 {
let avg_q = self.q_value_sums[idx] / self.q_value_counts[idx] as f64;
debug!(" [{}] {}: Q={:.4}", idx, exposure_names[idx], avg_q);

View File

@@ -42,8 +42,8 @@ impl DQNTrainer {
num_epochs: usize,
training_duration: std::time::Duration,
early_stopped: bool,
total_action_counts: [usize; 9], // 5 exposure levels
total_factored_action_counts: [usize; 81], // 81 factored actions
total_action_counts: [usize; 7], // 7 exposure levels
total_factored_action_counts: [usize; 63], // 63 factored actions (7 exp * 3 ord * 3 urg)
) -> Result<TrainingMetrics> {
let final_loss = total_loss / num_epochs as f64;
let avg_q_value_final = total_q_value / num_epochs as f64;
@@ -82,26 +82,26 @@ impl DQNTrainer {
metrics.add_metric("first_sharpe", first_sharpe);
metrics.add_metric("final_val_loss", final_val_loss);
// Action metrics: use factored 81-action space if branching, else 5 exposure levels
// Action metrics: use factored 63-action space if branching, else 7 exposure levels
let total_factored: usize = total_factored_action_counts.iter().sum();
let total_exposure: usize = total_action_counts.iter().sum();
let total_actions = total_factored.max(total_exposure);
if total_actions > 0 {
// Factored 81-action diversity (primary metric when branching)
// Factored 63-action diversity (primary metric when branching)
if total_factored > 0 {
let unique_factored = total_factored_action_counts.iter()
.filter(|&&count| count > 0).count();
let factored_diversity = (unique_factored as f64 / 81.0) * 100.0;
let factored_diversity = (unique_factored as f64 / 63.0) * 100.0;
metrics.add_metric("action_diversity", factored_diversity);
metrics.add_metric("factored_unique_actions", unique_factored as f64);
metrics.add_metric("action_space_size", 81.0);
metrics.add_metric("action_space_size", 63.0);
// Active factored actions (used >0.5% of the time)
let active_threshold = (total_factored as f64 * 0.005).max(1.0);
let active_count = total_factored_action_counts.iter()
.filter(|&&count| count as f64 >= active_threshold)
.count();
let active_diversity_pct = (active_count as f64 / 81.0) * 100.0;
let active_diversity_pct = (active_count as f64 / 63.0) * 100.0;
metrics.add_metric("active_actions_count", active_count as f64);
metrics.add_metric("active_diversity_pct", active_diversity_pct);
@@ -123,18 +123,18 @@ impl DQNTrainer {
let top5_coverage_pct = (top5_count as f64 / total_factored as f64) * 100.0;
metrics.add_metric("top5_coverage_pct", top5_coverage_pct);
} else {
// Fallback: exposure-only (9 levels)
// Fallback: exposure-only (7 levels)
let unique_actions = total_action_counts.iter()
.filter(|&&count| count > 0).count();
let action_diversity = (unique_actions as f64 / 9.0) * 100.0;
let action_diversity = (unique_actions as f64 / 7.0) * 100.0;
metrics.add_metric("action_diversity", action_diversity);
metrics.add_metric("action_space_size", 9.0);
metrics.add_metric("action_space_size", 7.0);
let active_threshold = (total_exposure as f64 * 0.005).max(1.0);
let active_count = total_action_counts.iter()
.filter(|&&count| count as f64 >= active_threshold)
.count();
let active_diversity_pct = (active_count as f64 / 5.0) * 100.0;
let active_diversity_pct = (active_count as f64 / 7.0) * 100.0;
metrics.add_metric("active_actions_count", active_count as f64);
metrics.add_metric("active_diversity_pct", active_diversity_pct);
@@ -158,13 +158,15 @@ impl DQNTrainer {
metrics.add_metric("total_actions", total_actions as f64);
// Buy/sell/hold always from 5-exposure space (meaningful for P&L)
// 0=Short100, 1=Short50, 2=Flat, 3=Long50, 4=Long100
// Buy/sell/hold from 7-exposure space (meaningful for P&L)
// 0=ShortSmall, 1=ShortHalf, 2=ShortFull, 3=Flat, 4=LongSmall, 5=LongHalf, 6=LongFull
let sell_count: usize = total_action_counts.get(0).copied().unwrap_or(0)
+ total_action_counts.get(1).copied().unwrap_or(0);
let hold_count: usize = total_action_counts.get(2).copied().unwrap_or(0);
let buy_count: usize = total_action_counts.get(3).copied().unwrap_or(0)
+ total_action_counts.get(4).copied().unwrap_or(0);
+ total_action_counts.get(1).copied().unwrap_or(0)
+ total_action_counts.get(2).copied().unwrap_or(0);
let hold_count: usize = total_action_counts.get(3).copied().unwrap_or(0);
let buy_count: usize = total_action_counts.get(4).copied().unwrap_or(0)
+ total_action_counts.get(5).copied().unwrap_or(0)
+ total_action_counts.get(6).copied().unwrap_or(0);
metrics.add_metric("buy_count", buy_count as f64);
metrics.add_metric("sell_count", sell_count as f64);
metrics.add_metric("hold_count", hold_count as f64);

View File

@@ -72,8 +72,8 @@ impl DQNTrainer {
let mut total_q_value = 0.0;
let mut total_gradient_norm = 0.0;
let mut total_reward = 0.0;
let mut total_action_counts = [0_usize; 9];
let mut total_factored_action_counts = [0_usize; 81];
let mut total_action_counts = [0_usize; 7];
let mut total_factored_action_counts = [0_usize; 63];
self.log_training_config().await;
@@ -400,7 +400,7 @@ impl DQNTrainer {
}
// M3: Q-value diagnostics — disabled, causes hang in single-threaded tokio
let q_diagnostics: Option<((f64, f64, f64), [f64; 9])> = None;
let q_diagnostics: Option<((f64, f64, f64), [f64; 7])> = None;
// Three-Phase Training Pipeline
let total_epochs = self.hyperparams.epochs;
@@ -1536,9 +1536,9 @@ impl DQNTrainer {
boundary: &Option<EpochBoundaryMetrics>,
monitor: &mut TrainingMonitor,
epoch_duration: std::time::Duration,
total_action_counts: &mut [usize; 9],
total_factored_action_counts: &mut [usize; 81],
mut q_diagnostics: Option<((f64, f64, f64), [f64; 9])>,
total_action_counts: &mut [usize; 7],
total_factored_action_counts: &mut [usize; 63],
mut q_diagnostics: Option<((f64, f64, f64), [f64; 7])>,
) -> Result<EpochLogOutput> {
// Calculate epoch metrics (average over training steps)
let (epoch_loss, epoch_q_value, epoch_gradient_norm) = match boundary {
@@ -1881,15 +1881,15 @@ impl DQNTrainer {
// With Flat-dominant policies, using total inflates the threshold and
// mechanically kills diversity when Flat > 80%. The metric should measure
// magnitude diversity WITHIN directional positions, not overall share.
let flat_count: usize = monitor.action_counts[3] + monitor.action_counts[4] + monitor.action_counts[5];
let flat_count: usize = monitor.action_counts[3]; // Flat is index 3 only
let directional_total = epoch_total.saturating_sub(flat_count).max(1);
let active_threshold = (directional_total as f64 * 0.01).max(1.0);
// action_counts[9] tracks direction*magnitude combos (b0*b1 = 9)
// Flat cells (indices 3-5) always count if Flat has any actions
// action_counts[7] tracks exposure levels: 0-2=Short, 3=Flat, 4-6=Long
// Flat cell (index 3) always counts if Flat has any actions
let active_dirmag = monitor.action_counts.iter().enumerate()
.filter(|&(i, &c)| {
if (3..6).contains(&i) {
c > 0 // Flat cells: count if any actions
if i == 3 {
c > 0 // Flat cell: count if any actions
} else {
c as f64 >= active_threshold // Directional cells: threshold on directional total
}