fix: monitoring 7→9 bin readback mismatch — diversity 15/81 → 81/81

The monitoring summary layout was shifted by 2 positions:
- Kernel writes: summary[5..14]=exp[9], [14..17]=order[3], [17..20]=urgency[3]
- Rust read (old): raw[5..12]=exp[7], raw[12..15]=order, raw[15..18]=urgency
- Rust read (new): raw[5..14]=exp[9], raw[14..17]=order, raw[17..20]=urgency

When num_actions changed 7→9, exp[7] and exp[8] became non-zero but Rust
read them as order_counts — the "order=3/3" was actually exp[7]+exp[8]+order[0].

Also fixed:
- MonitoringSummary.action_counts: [usize; 7] → [usize; 9]
- All downstream: monitoring.rs, metrics.rs, financials.rs, training_loop.rs
- Flat cell indices 3→{3,4,5} in diversity threshold
- exposure_names array expanded to 9 entries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-11 23:42:28 +02:00
parent e2bf655be2
commit 320a64bb90
5 changed files with 38 additions and 35 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 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 dir×mag level counts (9 bins: dir(3) × mag(3), e.g. ShortSmall=0 .. LongFull=8).
pub action_counts: [usize; 9],
/// Per-order-type counts (3: Market, LimitMaker, IoC).
pub order_counts: [usize; 3],
/// Per-urgency counts (3: Patient, Normal, Aggressive).
@@ -110,15 +110,17 @@ impl GpuMonitoringReducer {
min_reward: raw[2],
max_reward: raw[3],
sharpe_estimate: raw[4],
// Kernel layout: summary[5..14]=exp[9], summary[14..17]=order[3],
// summary[17..20]=urgency[3], summary[20]=total, summary[21]=trades
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[11] as usize, raw[12] as usize, raw[13] 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,
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,
})
}
}
@@ -131,7 +133,7 @@ mod tests {
fn test_monitoring_summary_default() {
let s = MonitoringSummary::default();
assert_eq!(s.total_experiences, 0);
assert_eq!(s.action_counts, [0; 7]);
assert_eq!(s.action_counts, [0; 9]);
}
#[test]

View File

@@ -29,7 +29,7 @@ pub(crate) struct EpochFinancials {
/// 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; 7],
action_counts: &[usize; 9],
initial_capital: f64,
bars_per_day: f64,
) -> EpochFinancials {
@@ -203,7 +203,7 @@ mod tests {
#[test]
fn test_empty_trade_stats() {
let ts = TradeStats::default();
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 9], 100_000.0, 390.0);
assert_eq!(f.total_trades, 0);
assert_eq!(f.sharpe, 0.0);
}
@@ -221,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; 7], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 9], 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);
@@ -242,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; 7], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 9], 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);
@@ -253,7 +253,7 @@ mod tests {
#[test]
fn test_action_distribution_7_actions() {
let mut actions = [0usize; 7];
let mut actions = [0usize; 9];
actions[0] = 10; // ShortSmall -> SELL
actions[1] = 10; // ShortHalf -> SELL
actions[3] = 20; // Flat -> HOLD
@@ -279,7 +279,7 @@ mod tests {
#[test]
fn test_action_distribution_all_indices_counted() {
let mut actions = [0usize; 7];
let mut actions = [0usize; 9];
for i in 0..7 { actions[i] = 10; }
let ts = TradeStats {
total_trades: 1,
@@ -324,7 +324,7 @@ mod tests {
step_returns,
done_flags,
};
let f = compute_epoch_financials(&ts, &[0; 7], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 9], 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.
@@ -356,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; 7], 100_000.0, 390.0);
let f = compute_epoch_financials(&ts, &[0; 9], 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,9 +12,9 @@ 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; 7], // 7 exposure levels (ShortSmall..LongFull)
pub(crate) action_counts: [usize; 9], // 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) q_value_counts: [usize; 9], // 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: 7 exposure * 3 order * 3 urgency = 63 actions.
@@ -40,9 +40,9 @@ impl TrainingMonitor {
Self {
epoch,
reward_history: Vec::new(),
action_counts: [0; 7],
action_counts: [0; 9],
q_value_sums: [0.0; 7],
q_value_counts: [0; 7],
q_value_counts: [0; 9],
order_type_counts: [0; 3],
urgency_counts: [0; 3],
factored_action_counts: [0; 63],
@@ -203,7 +203,7 @@ impl TrainingMonitor {
return Ok(());
}
let exposure_names = ["S_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "F_Small", "F_Half", "F_Full", "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 {
@@ -246,7 +246,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_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "F_Small", "F_Half", "F_Full", "L_Small", "L_Half", "L_Full"];
warn!(
"Q-VALUE DIVERGENCE at epoch {}: {}",
self.epoch,
@@ -265,7 +265,7 @@ impl TrainingMonitor {
if self.epoch % 10 == 0 {
let total_actions: usize = self.action_counts.iter().sum();
if total_actions > 0 {
let exposure_names = ["S_Small", "S_Half", "S_Full", "Flat", "L_Small", "L_Half", "L_Full"];
let exposure_names = ["S_Small", "S_Half", "S_Full", "F_Small", "F_Half", "F_Full", "L_Small", "L_Half", "L_Full"];
// Log all 7 exposure level actions
debug!(

View File

@@ -42,7 +42,7 @@ impl DQNTrainer {
num_epochs: usize,
training_duration: std::time::Duration,
early_stopped: bool,
total_action_counts: [usize; 7], // 7 exposure levels
total_action_counts: [usize; 9], // 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;

View File

@@ -72,7 +72,7 @@ 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; 7];
let mut total_action_counts = [0_usize; 9];
let mut total_factored_action_counts = [0_usize; 63];
self.log_training_config().await;
@@ -1506,7 +1506,7 @@ impl DQNTrainer {
boundary: &Option<EpochBoundaryMetrics>,
monitor: &mut TrainingMonitor,
epoch_duration: std::time::Duration,
total_action_counts: &mut [usize; 7],
total_action_counts: &mut [usize; 9],
total_factored_action_counts: &mut [usize; 63],
mut q_diagnostics: Option<((f64, f64, f64), [f64; 7])>,
) -> Result<EpochLogOutput> {
@@ -1851,17 +1851,18 @@ 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]; // Flat is index 3 only
// 9-bin layout: dir(3)×mag(3). Flat cells = indices 3,4,5 (dir=1 × mag=0,1,2).
// In practice only Flat×Half (index 4) is reachable (Flat forces mag=1).
let flat_count: usize = monitor.action_counts[3] + monitor.action_counts[4] + monitor.action_counts[5];
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[7] tracks exposure levels: 0-2=Short, 3=Flat, 4-6=Long
// Flat cell (index 3) always counts if Flat has any actions
// action_counts[9]: dir×mag combos. Flat cells (3,4,5) always count if non-zero.
let active_dirmag = monitor.action_counts.iter().enumerate()
.filter(|&(i, &c)| {
if i == 3 {
c > 0 // Flat cell: count if any actions
if (3..=5).contains(&i) {
c > 0 // Flat cells: count if any actions
} else {
c as f64 >= active_threshold // Directional cells: threshold on directional total
c as f64 >= active_threshold // Directional cells: threshold
}
}).count();
let active_ord = monitor.order_type_counts.iter()
@@ -1871,9 +1872,9 @@ impl DQNTrainer {
let active_factored = active_dirmag * active_ord * active_urg;
let diversity_pct = (active_factored as f64 / total_factored_space as f64) * 100.0;
// Max reachable dir*mag = (b0-1)*b1 + 1: Flat forces mag=Half, so only
// 1 Flat cell is reachable out of b1. Short and Long each have b1 cells.
let max_dirmag = (b0.saturating_sub(1)) * b1 + 1; // 2*3+1 = 7
// Max reachable dir*mag = (b0-1)*b1 + 1: Short and Long each have b1 mag options.
// Flat forces mag=Half, so only 1 of the 3 Flat cells is reachable.
let max_dirmag = (b0.saturating_sub(1)) * b1 + 1; // 2*3+1 = 7 (of 9 total)
info!(
"Epoch {}/{}: Action diversity={}/{} ({:.1}%) — dir*mag={}/{} order={}/{} urgency={}/{}",
epoch + 1, self.hyperparams.epochs,