Files
foxhunt/crates/ml-alpha/examples/alpha_rl_train.rs
jgrusewski 1c0c246ddf perf(rl): decouple diagnostic JSON writer to background thread
The 94KB per-step JSON record (130 fields, per-batch unit arrays)
took ~150ms CPU to build+serialize, blocking the GPU pipeline that
only needs ~13ms. Training was CPU-bound at 6.2 sps.

New architecture: training loop snapshots DiagFrame data (~0.1ms
memcpy from mapped-pinned to owned Vecs), sends via non-blocking
try_send on sync_channel(1). Background writer thread builds JSON
and writes to BufWriter at its own pace. Drops frames under
backpressure — acceptable for diagnostics.

Training loop per-step: 13ms GPU + 0.1ms snapshot = ~13.1ms.
Expected: ~50-77 sps at b=1024 on L40S (was 6.2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:30:41 +02:00

1293 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The per-step diag `json!{...}` block now spans ≥ 130 fields after the
// adaptive-clamp additions, exceeding serde_json's default macro recursion
// budget of 128. Bumping at the crate root keeps the expansion within limits.
#![recursion_limit = "256"]
//! Integrated RL trainer CLI (DQN + PPO on shared Mamba2 -> CfC encoder).
//!
//! Drives `IntegratedTrainer::step_with_lobsim` against a real `LobSimCuda`
//! backed by MBP-10 windows loaded via `MultiHorizonLoader::next_sequence_pair`
//! (R2) for true `(s_t, s_{t+1})` adjacency. The trainer combines:
//! * R3: GPU-resident EMA + advantage/return kernels.
//! * R4: GPU Thompson + Double-DQN argmax + log π_old kernels.
//! * R5: 7 RL controllers + target-net soft update.
//! * R6: GPU-pure env step (extract_realized_pnl_delta, apply_reward_scale,
//! actions_to_market_targets).
//! * R7a/R7b: host-lift of every per-step orchestration loop.
//! * R7c-data: true `h_{t+1}` / `V(s_{t+1})` / Bellman target on `h_{t+1}`.
//! * R7d: PER push/sample + off-policy DQN with stop-grad on encoder.
//!
//! Emits `alpha_rl_train_summary.json` on exit. Gate G8 (NaN abort): if any
//! per-head loss is non-finite at any step, the binary exits with code 2 so
//! the cluster smoke harness can terminate the workflow on first divergence.
//!
//! Usage:
//! alpha_rl_train \
//! --mbp10-data-dir /data/futures-baseline-mbp10/ES.FUT \
//! --predecoded-dir /feature-cache/predecoded \
//! --out /feature-cache/alpha-rl-runs/<sha> \
//! --n-steps 1000 \
//! --seed 16962 \
//! --instrument-mode front-month \
//! --gpu-idx 0
//!
//! Plan-doc reference: docs/superpowers/plans/2026-05-23-integrated-rl-trainer-rebuild.md
//! Section R8 (CLI binary + Argo template + dispatcher).
use anyhow::{Context, Result};
use clap::Parser;
use data::providers::databento::dbn_parser::InstrumentFilter;
use std::sync::mpsc;
// Use the canonical Action enum size, NOT a literal. Caught 2026-05-24
// during SP20 P4 dogfood: a `0..9` range survived the N_ACTIONS=9→11
// bump and silently dropped HalfFlat samples (a9, a10 showed 0% in
// diag despite P_MIN=0.02 floor guaranteeing 2% each). Per the
// emerging meta-pattern: any constant that mirrors a kernel-side
// structural dimension MUST reference the Rust const, not duplicate
// the literal value.
use ml_alpha::rl::common::{N_ACTIONS, FRD_N_ATOMS, FRD_N_HORIZONS};
use ml_alpha::rl::frd::FRD_OUT_DIM;
use ml_alpha::data::gpu_dataset::{GpuDataLoader, GpuDataset};
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
DEFAULT_OUTCOME_LABEL_COST_ES,
};
use ml_alpha::heads::HORIZONS;
use ml_alpha::rl::isv_slots::{
RL_ADVANTAGE_VAR_RATIO_EMA_INDEX, RL_ENTROPY_COEF_INDEX, RL_ENTROPY_OBSERVED_EMA_INDEX,
RL_GAMMA_INDEX, RL_KL_PI_EMA_INDEX, RL_LR_AUX_INDEX, RL_LR_BCE_INDEX, RL_LR_PI_BEST_LOSS_INDEX,
RL_LR_PI_INDEX, RL_LR_PI_LOSS_EMA_INDEX, RL_LR_PI_STEPS_SINCE_BEST_INDEX,
RL_LR_PI_WARMUP_COUNTER_INDEX, RL_LR_Q_BEST_LOSS_INDEX, RL_LR_Q_INDEX,
RL_LR_Q_LOSS_EMA_INDEX, RL_LR_Q_STEPS_SINCE_BEST_INDEX, RL_LR_Q_WARMUP_COUNTER_INDEX,
RL_LR_V_BEST_LOSS_INDEX, RL_LR_V_INDEX, RL_LR_V_LOSS_EMA_INDEX,
RL_LR_V_STEPS_SINCE_BEST_INDEX, RL_LR_V_WARMUP_COUNTER_INDEX,
RL_ADV_VAR_RATIO_CLAMP_INDEX, RL_ADV_VAR_RATIO_TARGET_INDEX, RL_ADV_VAR_STREAM_M2_INDEX,
RL_ADV_VAR_STREAM_MEAN_INDEX, RL_DIV_TARGET_INDEX, RL_ENTROPY_TARGET_FRAC_INDEX,
RL_EPS_BOOTSTRAP_INDEX, RL_IMPROVEMENT_THRESHOLD_INDEX, RL_KL_TARGET_INDEX,
RL_KURT_GAUSSIAN_INDEX, RL_KURT_LIFT_SCALE_INDEX, RL_KURT_NOISE_FLOOR_INDEX,
RL_K_LOOP_DIVISOR_INDEX, RL_K_LOOP_MAX_INDEX, RL_LOSS_LAMBDA_AUX_INDEX,
RL_LR_BOOTSTRAP_INDEX, RL_LR_DECAY_FACTOR_INDEX, RL_LR_LOSS_EMA_ALPHA_INDEX,
RL_LR_MAX_INDEX, RL_LR_MIN_INDEX, RL_LR_WARMUP_STEPS_INDEX,
RL_PLATEAU_PATIENCE_INDEX, RL_PPO_CLAMP_MARGIN_INDEX,
RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX, RL_Q_ARG_VS_PI_AGREE_INDEX,
RL_REWARD_CLAMP_LOSS_INDEX, RL_REWARD_CLAMP_WIN_INDEX, RL_REWARD_SCALE_BOOTSTRAP_INDEX,
RL_ROLLOUT_BOOTSTRAP_INDEX, RL_SCHULMAN_ADJUST_RATE_INDEX, RL_SCHULMAN_TOLERANCE_INDEX,
RL_STREAM_ALPHA_INDEX, RL_TAU_BOOTSTRAP_INDEX,
RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX, RL_MEAN_ABS_PNL_EMA_INDEX,
RL_POS_SCALED_REWARD_MAX_INDEX, RL_POS_SCALED_REWARD_MAX_EMA_INDEX,
RL_REWARD_CLAMP_MARGIN_INDEX, RL_REWARD_CLAMP_RATIO_INDEX,
RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX, RL_REWARD_CLAMP_CLIP_RATE_TARGET_INDEX,
RL_C51_V_MAX_INDEX, RL_C51_V_MIN_INDEX,
RL_Q_DISTILL_LAMBDA_INDEX, RL_Q_DISTILL_TEMPERATURE_INDEX, RL_Q_DISTILL_KL_EMA_INDEX,
RL_NEG_SCALED_REWARD_MAX_INDEX, RL_NEG_SCALED_REWARD_MAX_EMA_INDEX,
RL_Q_DISTILL_KL_TARGET_INDEX, RL_REWARD_SCALE_MIN_INDEX,
RL_MEAN_TRADE_DURATION_EMA_INDEX, RL_N_ROLLOUT_STEPS_INDEX, RL_PER_ALPHA_INDEX,
RL_PI_GRAD_NORM_EMA_INDEX, RL_PPO_CLIP_INDEX, RL_PPO_LOG_RATIO_ABS_MAX_INDEX,
RL_PPO_RATIO_CLAMP_MAX_INDEX, RL_Q_DIVERGENCE_EMA_INDEX, RL_Q_GRAD_NORM_EMA_INDEX,
RL_REWARD_SCALE_INDEX, RL_TARGET_TAU_INDEX, RL_TD_KURTOSIS_CLAMP_INDEX,
RL_TD_KURTOSIS_EMA_INDEX, RL_TD_KURT_STREAM_M2_INDEX, RL_TD_KURT_STREAM_M4_INDEX,
RL_TD_KURT_STREAM_MEAN_INDEX, RL_V_GRAD_NORM_EMA_INDEX,
// P1+P2 feature metrics
RL_POPART_MEAN_INDEX, RL_POPART_SIGMA_INDEX, RL_POPART_VAR_INDEX,
RL_SPECTRAL_NORM_MAX_INDEX, RL_SPECTRAL_DECOUPLE_LAMBDA_INDEX,
RL_Q_BIAS_EMA_INDEX, RL_Q_BIAS_CORRECTION_INDEX,
RL_LR_SCALE_Q_INDEX, RL_LR_SCALE_PI_INDEX, RL_LR_SCALE_V_INDEX, RL_LR_SCALE_IQN_INDEX,
RL_OUTCOME_AUX_LAMBDA_INDEX,
RL_SAC_ALPHA_INDEX, RL_SAC_ENTROPY_TARGET_INDEX,
RL_ACTION_ENTROPY_EMA_INDEX,
};
use ml_alpha::trainer::diag_staging::DiagStaging;
use ml_alpha::trainer::integrated::{
IntegratedStepStats, IntegratedTrainer, IntegratedTrainerConfig,
};
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
use serde::Serialize;
use serde_json::json;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::process;
#[derive(Parser)]
#[command(name = "alpha_rl_train")]
struct Cli {
/// Directory containing MBP-10 .dbn.zst files. Per
/// `feedback_mbp10_mandatory`, this argument is required — no
/// synthetic-data fallback in the production training path.
#[arg(long)]
mbp10_data_dir: PathBuf,
/// Directory holding predecoded sidecar caches (created on miss).
#[arg(long)]
predecoded_dir: PathBuf,
/// Output directory for summary JSON + per-step log artefacts.
#[arg(long)]
out: PathBuf,
/// Total `step_with_lobsim` calls to run. The validation smoke
/// (audit prerequisite) ships with 1000; production scale-up runs
/// 50k+. Each call processes one `(s_t, s_{t+1})` sequence pair
/// across `n_backtests` parallel LobSim instances.
#[arg(long, default_value_t = 1000)]
n_steps: usize,
/// Encoder sequence length (snapshots per step). Must match
/// `PerceptionTrainerConfig::seq_len`. Default 32 matches the
/// validated single-scale `1:32` window from alpha_train.
#[arg(long, default_value_t = 32)]
seq_len: usize,
/// Batch dimension — number of parallel LobSim backtests run in
/// lockstep per step. Larger batches amortise the encoder forward
/// cost across more trajectories but push GPU memory pressure
/// linearly. Default 16 — fixes the b_size=1 signal starvation
/// per `pearl_b_size_1_signal_starvation_blocks_q_learning`
/// (Q stayed at uniform baseline ln(21)=3.04 across all b_size=1
/// smokes regardless of controller tuning; 16 parallel
/// transitions per Adam step give 16× the gradient signal). Was
/// 1 (validation-smoke default); production sweep can push to
/// 32-64 on L40S 48GB.
#[arg(long, default_value_t = 16)]
n_backtests: usize,
/// PER buffer capacity. At b_size=16, capacity/16 = unique steps
/// retained. 32768/16 = 2048 steps of replay depth.
#[arg(long, default_value_t = 32768)]
per_capacity: usize,
/// Random seed pinning RNG + Xavier init + ISV controller bootstrap
/// state. Per `pearl_scoped_init_seed_for_reproducibility`.
#[arg(long, default_value_t = 16962)]
seed: u64,
/// MBP-10 instrument filter — `all`, `front-month`, or `id=<N>`.
/// `front-month` is the canonical setting for ES (auto-detects the
/// dominant contract per file, handles quarterly roll).
#[arg(long, default_value = "front-month", value_parser = parse_instrument_mode)]
instrument_mode: InstrumentFilter,
/// CUDA device index. Single-GPU dev (RTX 3050 Ti) uses 0; cluster
/// L40S/H100 also 0 (one GPU per pod per the alpha-rl template).
#[arg(long, default_value_t = 0)]
gpu_idx: usize,
/// Round-trip trade cost in price units for the D-style outcome
/// labels (consumed by the perception aux head, not by the RL
/// path directly — but the loader needs it). Default 0.5 = 2 ticks
/// × $0.25/tick for ES.
#[arg(long, default_value_t = DEFAULT_OUTCOME_LABEL_COST_ES)]
outcome_label_cost: f32,
/// How often (in steps) to flush a progress line to stderr. The
/// summary JSON is written ONCE at the end; this controls in-flight
/// visibility only.
#[arg(long, default_value_t = 100)]
log_every: usize,
/// Walk-forward fold index for the multi-fold G8 gate (per
/// `pearl_single_window_oos_is_not_oos` — a single window is NOT
/// out-of-sample). Slices the MBP-10 file list into K equal-sized
/// blocks; fold k trains on blocks [0..k+1) and evaluates on block
/// [k+1] (purged walk-forward). At `fold_idx=0` the first block
/// is the train window, second is eval. At `fold_idx=K-2` the
/// trainer sees almost all the data and evaluates on the final
/// block. The dispatcher (`scripts/argo-alpha-rl.sh --n-folds K`)
/// fans out K-1 workflows, one per fold; the aggregator computes
/// mean ± SD of the per-fold profit_factor.
///
/// Default 0 + n-folds=1 = single-window mode (no eval split).
#[arg(long, default_value_t = 0)]
fold_idx: usize,
/// Total walk-forward fold count. Set with `--fold-idx` for the
/// multi-fold G8 gate. n_folds=1 disables the train/eval split
/// (single-window mode — only meaningful for smoke validation).
/// n_folds≥3 enables a real evaluation: train on first
/// `fold_idx + 1` of `n_folds` data blocks, evaluate on the next.
#[arg(long, default_value_t = 1)]
n_folds: usize,
/// Eval-phase step count. After the train phase completes its
/// `--n-steps`, the eval phase runs `--n-eval-steps` additional
/// steps on the held-out fold using the same trainer
/// (acknowledged minor in-eval learning contamination at b_size=1
/// — pure eval mode is a follow-up). LobSim trade records are
/// drained at the END so the summary reflects eval-phase trades
/// only (train-phase trades are flushed when the eval phase
/// starts).
///
/// 0 disables the eval phase entirely (single-window training
/// only — the summary's profit_factor will reflect the full run
/// including train).
#[arg(long, default_value_t = 0)]
n_eval_steps: usize,
/// Per-step diagnostic JSONL path. One record per step capturing:
/// * step number, wall time
/// * loss components (l_bce/q/pi/v/aux/total) + λs (5)
/// * all 7 RL controller outputs (γ τ ε coef n_roll per_α scale)
/// * all 5 per-head learning rates (lr_bce/q/pi/v/aux)
/// * all 7 EMA inputs the controllers consume
/// * replay buffer length
/// * per-step reward + done aggregates (DtoH b_size floats per stat)
/// * per-step action histogram (counts per action class)
///
/// Default: `<out>/diag.jsonl`. Pass `--diag-jsonl /dev/null` (or
/// any unwritable path that errors at open) to disable. The dump
/// happens AFTER each `step_with_lobsim` so it sees the freshly-
/// adapted ISV values. Writes are line-buffered + flushed every
/// `log_every` steps so a `tail -f` shows in-flight progress.
///
/// Critically: this provides the per-step diagnostic visibility
/// the cluster smoke needs to detect anomalies early. Without it,
/// the smoke is "blind" — only the final summary is observable,
/// and a controller drifting off-anchor mid-run is invisible until
/// it produces a non-finite loss (G8 NaN abort).
#[arg(long)]
diag_jsonl: Option<PathBuf>,
/// Resume from checkpoint file path.
#[arg(long)]
resume_from: Option<PathBuf>,
/// Checkpoint interval in steps (default: 5000). Set to 0 to disable.
#[arg(long, default_value = "5000")]
checkpoint_every: usize,
/// Diagnostic JSONL write interval (default: 1 = every step).
/// At b=1024 the 94KB JSON record takes ~150ms of CPU time per step,
/// dwarfing the ~13ms GPU pipeline. Set to 10-100 to skip the heavy
/// diagnostic construction on most steps and let the GPU run free.
#[arg(long, default_value = "1")]
diag_every: usize,
}
fn parse_instrument_mode(s: &str) -> Result<InstrumentFilter, String> {
let trimmed = s.trim();
if trimmed.eq_ignore_ascii_case("all") {
return Ok(InstrumentFilter::All);
}
if trimmed.eq_ignore_ascii_case("front-month")
|| trimmed.eq_ignore_ascii_case("front_month")
{
return Ok(InstrumentFilter::FrontMonth);
}
if let Some(num) = trimmed.strip_prefix("id=") {
let id: u32 = num.parse().map_err(|e| {
format!("instrument-mode: expected `id=<u32>` but failed to parse '{num}': {e}")
})?;
return Ok(InstrumentFilter::Id(id));
}
Err(format!(
"instrument-mode: expected `all`, `front-month`, or `id=<N>`; got '{s}'"
))
}
#[derive(Serialize, Default)]
struct AlphaRlTrainSummary {
n_steps_planned: usize,
n_steps_completed: usize,
seq_len: usize,
n_backtests: usize,
per_capacity: usize,
seed: u64,
/// True iff `n_steps_completed == n_steps_planned` AND no NaN
/// abort fired. False if early-terminated for any reason (NaN,
/// loader EOF, panic — though panics also exit nonzero).
completed_clean: bool,
/// Final-step loss components (raw, not λ-weighted). All NaN if
/// trainer never returned a finite step.
final_l_bce: f32,
final_l_q: f32,
final_l_pi: f32,
final_l_v: f32,
final_l_aux: f32,
final_l_total: f32,
/// Replay buffer state at exit.
final_replay_len: usize,
/// Step at which the first NaN was observed, if any. -1 means
/// no NaN observed.
nan_abort_step: i64,
}
/// Owned snapshot of one training step's diagnostic data. Sent to the
/// writer thread via `sync_channel(1)`. All fields are owned (no
/// borrows) so the frame is `Send`.
struct DiagFrame {
step: usize,
elapsed_s: f32,
stats: IntegratedStepStats,
replay_len: usize,
last_k_updates: usize,
n_backtests: usize,
// Per-batch arrays cloned from mapped-pinned staging.
rewards: Vec<f32>,
dones: Vec<f32>,
actions: Vec<i32>,
raw_rewards: Vec<f32>,
trade_duration: Vec<f32>,
outcome_ema: Vec<f32>,
position_lots: Vec<i32>,
pyramid_count: Vec<i32>,
unit_entry_price: Vec<f32>,
unit_entry_step: Vec<i32>,
unit_lots: Vec<i32>,
unit_trail: Vec<f32>,
close_unit_index: Vec<i32>,
frd_logits: Vec<f32>,
// ISV snapshot (full slot vector).
isv: Vec<f32>,
}
/// Writer thread: receives `DiagFrame`s, builds the ~130-field JSON
/// record, and writes to `BufWriter<File>`. Runs on a dedicated OS
/// thread so JSON construction (~150ms) never blocks the GPU pipeline.
fn diag_writer_thread(
rx: mpsc::Receiver<DiagFrame>,
mut diag: BufWriter<std::fs::File>,
) {
// Per-step windowed action histogram (EMA, α=1/1000).
let mut windowed_act_hist: [f32; N_ACTIONS] = [0.0; N_ACTIONS];
const WINDOWED_ACT_ALPHA: f32 = 1.0 / 1000.0;
// Cumulative trading stats.
let mut pnl_cum_usd: f64 = 0.0;
let mut win_count: u64 = 0;
let mut total_trades: u64 = 0;
let mut hold_time_sum: f64 = 0.0;
let mut trail_fired_total: u64 = 0;
let mut trail_tighten_total: u64 = 0;
let mut trail_loosen_total: u64 = 0;
let mut pyramid_added_total: u64 = 0;
let mut partial_flat_total: u64 = 0;
let mut partial_flat_long_total: u64 = 0;
let mut partial_flat_short_total: u64 = 0;
let mut conf_gate_total: u64 = 0;
let mut frd_gate_total: u64 = 0;
let mut heat_cap_total: u64 = 0;
let mut flush_counter: usize = 0;
while let Ok(f) = rx.recv() {
let isv = &f.isv;
// Action histogram.
let mut act_hist = [0u32; N_ACTIONS];
for &a in &f.actions {
if (a as usize) < N_ACTIONS {
act_hist[a as usize] += 1;
}
}
let total_actions: u32 = act_hist.iter().sum();
if total_actions > 0 {
for i in 0..N_ACTIONS {
let p_step = (act_hist[i] as f32) / (total_actions as f32);
windowed_act_hist[i] = (1.0 - WINDOWED_ACT_ALPHA)
* windowed_act_hist[i]
+ WINDOWED_ACT_ALPHA * p_step;
}
}
let win_sum: f32 = windowed_act_hist.iter().sum();
let action_entropy: f32 = if win_sum > 1e-9 {
windowed_act_hist
.iter()
.filter(|&&p| p > 1e-9)
.map(|&p_raw| {
let p = p_raw / win_sum;
-p * p.ln()
})
.sum()
} else {
0.0
};
let reward_sum: f32 = f.rewards.iter().sum();
let reward_max = f.rewards.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let reward_min = f.rewards.iter().cloned().fold(f32::INFINITY, f32::min);
let reward_abs_max = f.rewards.iter().map(|r| r.abs()).fold(0.0f32, f32::max);
let done_count: u32 = f.dones.iter().map(|&d| if d > 0.5 { 1 } else { 0 }).sum();
// Per-trade cumulative stats (surfer validation).
let current_scale = isv[ml_alpha::rl::isv_slots::RL_REWARD_SCALE_INDEX];
for b in 0..f.n_backtests {
if f.dones[b] > 0.5 {
let pnl_usd = if current_scale > 1e-9 {
f.rewards[b] as f64 / current_scale as f64
} else {
f.rewards[b] as f64
};
pnl_cum_usd += pnl_usd;
total_trades += 1;
if pnl_usd > 0.0 { win_count += 1; }
hold_time_sum += f.trade_duration[b] as f64;
}
}
let win_rate = if total_trades > 0 { win_count as f64 / total_trades as f64 } else { 0.0 };
let avg_hold = if total_trades > 0 { hold_time_sum / total_trades as f64 } else { 0.0 };
// Per-step counters from action histogram + ISV diag slots.
let trail_fired_step = isv[ml_alpha::rl::isv_slots::RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
let tighten_step = act_hist[7] as u64;
let loosen_step = act_hist[8] as u64;
let pyramid_add_step = isv[ml_alpha::rl::isv_slots::RL_PYRAMID_ADD_COUNT_INDEX] as u64;
let half_flat_long_step = act_hist[9] as u64;
let half_flat_short_step = act_hist[10] as u64;
let partial_flat_step = half_flat_long_step + half_flat_short_step;
let conf_gate_step = isv[ml_alpha::rl::isv_slots::RL_CONF_GATE_FIRED_COUNT_INDEX] as u64;
let frd_gate_step = isv[ml_alpha::rl::isv_slots::RL_FRD_GATE_FIRED_COUNT_INDEX] as u64;
let heat_cap_step = isv[ml_alpha::rl::isv_slots::RL_HEAT_CAP_FIRED_COUNT_INDEX] as u64;
trail_fired_total += trail_fired_step;
trail_tighten_total += tighten_step;
trail_loosen_total += loosen_step;
pyramid_added_total += pyramid_add_step;
partial_flat_total += partial_flat_step;
partial_flat_long_total += half_flat_long_step;
partial_flat_short_total += half_flat_short_step;
conf_gate_total += conf_gate_step;
frd_gate_total += frd_gate_step;
heat_cap_total += heat_cap_step;
// Build per-batch per-unit arrays for diag.
let units_diag = {
let b = f.n_backtests;
let unit_active_host: Vec<u8> = f.unit_lots
.iter()
.map(|&l| if l != 0 { 1u8 } else { 0u8 })
.collect();
let mut entry_price_arr = Vec::with_capacity(b);
let mut entry_step_arr = Vec::with_capacity(b);
let mut lots_arr = Vec::with_capacity(b);
let mut trail_arr = Vec::with_capacity(b);
let mut active_arr = Vec::with_capacity(b);
for batch in 0..b {
let off = batch * 4;
entry_price_arr.push(&f.unit_entry_price[off..off + 4]);
entry_step_arr.push(&f.unit_entry_step[off..off + 4]);
lots_arr.push(&f.unit_lots[off..off + 4]);
trail_arr.push(&f.unit_trail[off..off + 4]);
active_arr.push(&unit_active_host[off..off + 4]);
}
json!({
"entry_price": entry_price_arr,
"entry_step": entry_step_arr,
"lots": lots_arr,
"trail_distance": trail_arr,
"active_mask": active_arr,
"unit_count": f.pyramid_count,
})
};
// FRD diag — per-horizon softmax entropy + argmax index.
let frd_diag = {
let frd_logits = &f.frd_logits;
let mut per_h_entropy = [0.0_f32; FRD_N_HORIZONS];
let mut per_h_argmax_sum = [0.0_f32; FRD_N_HORIZONS];
for b in 0..f.n_backtests {
for h in 0..FRD_N_HORIZONS {
let off = b * FRD_OUT_DIM + h * FRD_N_ATOMS;
let row = &frd_logits[off..off + FRD_N_ATOMS];
let max_l = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let denom: f32 = row.iter().map(|x| (x - max_l).exp()).sum();
let mut entropy = 0.0_f32;
let mut argmax_idx = 0;
let mut argmax_v = f32::NEG_INFINITY;
for (a, x) in row.iter().enumerate() {
let p = (x - max_l).exp() / denom;
if p > 1e-9 {
entropy -= p * p.ln();
}
if *x > argmax_v {
argmax_v = *x;
argmax_idx = a;
}
}
per_h_entropy[h] += entropy;
per_h_argmax_sum[h] += argmax_idx as f32;
}
}
let b_f = f.n_backtests.max(1) as f32;
json!({
"h1": { "entropy_mean": per_h_entropy[0] / b_f, "argmax_mean": per_h_argmax_sum[0] / b_f },
"h2": { "entropy_mean": per_h_entropy[1] / b_f, "argmax_mean": per_h_argmax_sum[1] / b_f },
"h3": { "entropy_mean": per_h_entropy[2] / b_f, "argmax_mean": per_h_argmax_sum[2] / b_f },
})
};
let stats = &f.stats;
let record = json!({
"step": f.step,
"elapsed_s": f.elapsed_s,
"loss": {
"bce": stats.l_bce,
"q": stats.l_q,
"pi": stats.l_pi,
"v": stats.l_v,
"aux": stats.l_aux,
"frd": stats.l_frd,
"total": stats.l_total,
},
"lambdas": {
"bce": stats.lambdas.bce,
"q": stats.lambdas.q,
"pi": stats.lambdas.pi,
"v": stats.lambdas.v,
"aux": stats.lambdas.aux,
"frd": stats.lambdas.frd,
},
"isv_out": {
"gamma": isv[RL_GAMMA_INDEX],
"target_tau": isv[RL_TARGET_TAU_INDEX],
"ppo_clip_eps": isv[RL_PPO_CLIP_INDEX],
"entropy_coef": isv[RL_ENTROPY_COEF_INDEX],
"n_rollout_steps": isv[RL_N_ROLLOUT_STEPS_INDEX],
"per_alpha": isv[RL_PER_ALPHA_INDEX],
"reward_scale": isv[RL_REWARD_SCALE_INDEX],
},
"isv_lr": {
"bce": isv[RL_LR_BCE_INDEX],
"q": isv[RL_LR_Q_INDEX],
"pi": isv[RL_LR_PI_INDEX],
"v": isv[RL_LR_V_INDEX],
"aux": isv[RL_LR_AUX_INDEX],
},
"isv_ema_in": {
"mean_trade_duration": isv[RL_MEAN_TRADE_DURATION_EMA_INDEX],
"q_divergence": isv[RL_Q_DIVERGENCE_EMA_INDEX],
"kl_pi": isv[RL_KL_PI_EMA_INDEX],
"entropy_observed": isv[RL_ENTROPY_OBSERVED_EMA_INDEX],
"advantage_var_ratio": isv[RL_ADVANTAGE_VAR_RATIO_EMA_INDEX],
"td_kurtosis": isv[RL_TD_KURTOSIS_EMA_INDEX],
"mean_abs_pnl": isv[RL_MEAN_ABS_PNL_EMA_INDEX],
},
"grad_norm_ema": {
"q": isv[RL_Q_GRAD_NORM_EMA_INDEX],
"pi": isv[RL_PI_GRAD_NORM_EMA_INDEX],
"v": isv[RL_V_GRAD_NORM_EMA_INDEX],
},
"lr_plateau": {
"q": { "loss_ema": isv[RL_LR_Q_LOSS_EMA_INDEX],
"best": isv[RL_LR_Q_BEST_LOSS_INDEX],
"stale": isv[RL_LR_Q_STEPS_SINCE_BEST_INDEX],
"warmup": isv[RL_LR_Q_WARMUP_COUNTER_INDEX] },
"pi": { "loss_ema": isv[RL_LR_PI_LOSS_EMA_INDEX],
"best": isv[RL_LR_PI_BEST_LOSS_INDEX],
"stale": isv[RL_LR_PI_STEPS_SINCE_BEST_INDEX],
"warmup": isv[RL_LR_PI_WARMUP_COUNTER_INDEX] },
"v": { "loss_ema": isv[RL_LR_V_LOSS_EMA_INDEX],
"best": isv[RL_LR_V_BEST_LOSS_INDEX],
"stale": isv[RL_LR_V_STEPS_SINCE_BEST_INDEX],
"warmup": isv[RL_LR_V_WARMUP_COUNTER_INDEX] },
},
"replay_len": f.replay_len,
"rewards": {
"sum": reward_sum,
"max": reward_max,
"min": reward_min,
"abs_max": reward_abs_max,
"scaled_pre_clamp_max":
isv[RL_MAX_ABS_SCALED_REWARD_PRE_CLAMP_INDEX],
"pos_scaled_max":
isv[RL_POS_SCALED_REWARD_MAX_INDEX],
"pos_scaled_max_ema":
isv[RL_POS_SCALED_REWARD_MAX_EMA_INDEX],
"clip_rate_ema":
isv[RL_REWARD_CLAMP_CLIP_RATE_EMA_INDEX],
"c51_v_max":
isv[RL_C51_V_MAX_INDEX],
"c51_v_min":
isv[RL_C51_V_MIN_INDEX],
"q_distill_kl_ema":
isv[RL_Q_DISTILL_KL_EMA_INDEX],
"neg_scaled_max":
isv[RL_NEG_SCALED_REWARD_MAX_INDEX],
"neg_scaled_max_ema":
isv[RL_NEG_SCALED_REWARD_MAX_EMA_INDEX],
},
"ppo": {
"ratio_clamp_max": isv[RL_PPO_RATIO_CLAMP_MAX_INDEX],
"log_ratio_abs_max": isv[RL_PPO_LOG_RATIO_ABS_MAX_INDEX],
},
"streaming": {
"adv_var": {
"mean": isv[RL_ADV_VAR_STREAM_MEAN_INDEX],
"m2": isv[RL_ADV_VAR_STREAM_M2_INDEX],
"clamp": isv[RL_ADV_VAR_RATIO_CLAMP_INDEX],
},
"td_kurt": {
"mean": isv[RL_TD_KURT_STREAM_MEAN_INDEX],
"m2": isv[RL_TD_KURT_STREAM_M2_INDEX],
"m4": isv[RL_TD_KURT_STREAM_M4_INDEX],
"clamp": isv[RL_TD_KURTOSIS_CLAMP_INDEX],
},
},
"k_loop": {
"k_updates": f.last_k_updates,
"divisor": isv[RL_K_LOOP_DIVISOR_INDEX],
"max": isv[RL_K_LOOP_MAX_INDEX],
},
"isv_config": {
"reward_clamp_win": isv[RL_REWARD_CLAMP_WIN_INDEX],
"reward_clamp_loss": isv[RL_REWARD_CLAMP_LOSS_INDEX],
"kl_target": isv[RL_KL_TARGET_INDEX],
"improvement_threshold": isv[RL_IMPROVEMENT_THRESHOLD_INDEX],
"plateau_patience": isv[RL_PLATEAU_PATIENCE_INDEX],
"div_target": isv[RL_DIV_TARGET_INDEX],
"entropy_target_frac": isv[RL_ENTROPY_TARGET_FRAC_INDEX],
"kurt_lift_scale": isv[RL_KURT_LIFT_SCALE_INDEX],
"ppo_clamp_margin": isv[RL_PPO_CLAMP_MARGIN_INDEX],
"lr_warmup_steps": isv[RL_LR_WARMUP_STEPS_INDEX],
"lr_bootstrap": isv[RL_LR_BOOTSTRAP_INDEX],
"lr_min": isv[RL_LR_MIN_INDEX],
"lr_max": isv[RL_LR_MAX_INDEX],
"lr_loss_ema_alpha": isv[RL_LR_LOSS_EMA_ALPHA_INDEX],
"lr_decay_factor": isv[RL_LR_DECAY_FACTOR_INDEX],
"loss_lambda_aux": isv[RL_LOSS_LAMBDA_AUX_INDEX],
"schulman_tolerance": isv[RL_SCHULMAN_TOLERANCE_INDEX],
"schulman_adjust_rate": isv[RL_SCHULMAN_ADJUST_RATE_INDEX],
"stream_alpha": isv[RL_STREAM_ALPHA_INDEX],
"kurt_gaussian": isv[RL_KURT_GAUSSIAN_INDEX],
"kurt_noise_floor": isv[RL_KURT_NOISE_FLOOR_INDEX],
"tau_bootstrap": isv[RL_TAU_BOOTSTRAP_INDEX],
"eps_bootstrap": isv[RL_EPS_BOOTSTRAP_INDEX],
"rollout_bootstrap": isv[RL_ROLLOUT_BOOTSTRAP_INDEX],
"reward_scale_bootstrap":isv[RL_REWARD_SCALE_BOOTSTRAP_INDEX],
"ppo_ratio_clamp_bootstrap": isv[RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX],
"reward_clamp_margin": isv[RL_REWARD_CLAMP_MARGIN_INDEX],
"reward_clamp_ratio": isv[RL_REWARD_CLAMP_RATIO_INDEX],
"reward_clamp_clip_rate_target": isv[RL_REWARD_CLAMP_CLIP_RATE_TARGET_INDEX],
"q_distill_lambda": isv[RL_Q_DISTILL_LAMBDA_INDEX],
"q_distill_temperature": isv[RL_Q_DISTILL_TEMPERATURE_INDEX],
"q_distill_kl_target": isv[RL_Q_DISTILL_KL_TARGET_INDEX],
"sac_alpha": isv[RL_SAC_ALPHA_INDEX],
"sac_entropy_target": isv[RL_SAC_ENTROPY_TARGET_INDEX],
"action_entropy_ema": isv[RL_ACTION_ENTROPY_EMA_INDEX],
"reward_scale_min": isv[RL_REWARD_SCALE_MIN_INDEX],
},
"q_pi_agree_ema": isv[RL_Q_ARG_VS_PI_AGREE_INDEX],
"controller_branch": {
"rollout_steps_input": isv[RL_ADVANTAGE_VAR_RATIO_EMA_INDEX],
"rollout_steps_target": isv[RL_ADV_VAR_RATIO_TARGET_INDEX],
"ppo_clip_input": isv[RL_KL_PI_EMA_INDEX],
"ppo_clip_target": 0.01f32,
"target_tau_input": isv[RL_Q_DIVERGENCE_EMA_INDEX],
"target_tau_target": 0.01f32,
"per_alpha_input": isv[RL_TD_KURTOSIS_EMA_INDEX],
"per_alpha_target": 0.6f32,
},
"done_count": done_count,
"action_hist": act_hist.to_vec(),
"action_entropy": action_entropy,
"position": {
"lots": f.position_lots,
},
"units": units_diag,
"trail": {
"fired_count_step": trail_fired_step,
"fired_count_total": trail_fired_total,
"tightened_count_step": tighten_step,
"loosened_count_step": loosen_step,
"tightened_count_total": trail_tighten_total,
"loosened_count_total": trail_loosen_total,
},
"pyramid": {
"added_count_step": pyramid_add_step,
"added_count_total": pyramid_added_total,
"units_distribution": f.pyramid_count,
"max_units_reached": f.pyramid_count.iter().any(|&c| c >= 4),
},
"partial_flat": {
"fired_count_step": partial_flat_step,
"fired_count_total": partial_flat_total,
"long_count_total": partial_flat_long_total,
"short_count_total": partial_flat_short_total,
"close_unit_index": f.close_unit_index,
},
"confidence_gate": {
"gated_count_step": conf_gate_step,
"gated_count_total": conf_gate_total,
},
"position_heat": {
"capped_count_step": heat_cap_step,
"capped_count_total": heat_cap_total,
"heat_max_lots": isv[ml_alpha::rl::isv_slots::RL_HEAT_CAP_MAX_LOTS_INDEX],
},
"anti_martingale": {
"outcome_ema": f.outcome_ema,
"kappa": isv[ml_alpha::rl::isv_slots::RL_ANTIMARTINGALE_KAPPA_INDEX],
},
"frd_gate": {
"gated_count_step": frd_gate_step,
"gated_count_total": frd_gate_total,
},
"trading": {
"pnl_cum_usd": pnl_cum_usd,
"total_trades": total_trades,
"win_rate": win_rate,
"avg_hold_steps": avg_hold,
"raw_reward_sum": f.raw_rewards.iter().sum::<f32>(),
},
"frd": frd_diag,
"popart": {
"mean": isv[RL_POPART_MEAN_INDEX],
"sigma": isv[RL_POPART_SIGMA_INDEX],
"var": isv[RL_POPART_VAR_INDEX],
},
"spectral": {
"norm_max_config": isv[RL_SPECTRAL_NORM_MAX_INDEX],
"decouple_lambda": isv[RL_SPECTRAL_DECOUPLE_LAMBDA_INDEX],
},
"q_bias": {
"ema": isv[RL_Q_BIAS_EMA_INDEX],
"correction": isv[RL_Q_BIAS_CORRECTION_INDEX],
},
"per_branch_lr": {
"scale_q": isv[RL_LR_SCALE_Q_INDEX],
"scale_pi": isv[RL_LR_SCALE_PI_INDEX],
"scale_v": isv[RL_LR_SCALE_V_INDEX],
"scale_iqn": isv[RL_LR_SCALE_IQN_INDEX],
},
"outcome_aux": {
"lambda": isv[RL_OUTCOME_AUX_LAMBDA_INDEX],
},
});
if let Err(e) = writeln!(diag, "{}", record) {
eprintln!("diag writer: writeln error: {e}");
break;
}
flush_counter += 1;
if flush_counter % 100 == 0 {
let _ = diag.flush();
}
}
// Final flush on channel close.
let _ = diag.flush();
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
std::fs::create_dir_all(&cli.out)
.with_context(|| format!("mkdir -p {}", cli.out.display()))?;
std::fs::create_dir_all(&cli.predecoded_dir)
.with_context(|| format!("mkdir -p {}", cli.predecoded_dir.display()))?;
let dev = MlDevice::cuda(cli.gpu_idx)
.with_context(|| format!("MlDevice::cuda({})", cli.gpu_idx))?;
eprintln!("CUDA device {} initialised", cli.gpu_idx);
// Loader: paired (s_t, s_{t+1}) sequences. inference_only=false so
// next_sequence_pair is available (the labels it preloads are
// unused by step_with_lobsim, but the API requires the label
// precomputation pass per `feedback_no_partial_refactor`).
let all_files = discover_mbp10_files_sorted(&cli.mbp10_data_dir)
.with_context(|| format!("discover_mbp10_files_sorted({})", cli.mbp10_data_dir.display()))?;
anyhow::ensure!(
!all_files.is_empty(),
"no MBP-10 .dbn.zst files under {}",
cli.mbp10_data_dir.display()
);
eprintln!("loader: {} MBP-10 files total", all_files.len());
// Walk-forward fold split per `pearl_single_window_oos_is_not_oos`.
// Slice the file list into `n_folds` equal blocks. fold k uses
// blocks [0..=k] for training and block [k+1] for evaluation. With
// n_folds=1 (default) train_files = all_files and eval_files is
// empty (single-window mode, no eval phase).
let (train_files, eval_files) = if cli.n_folds <= 1 || cli.n_eval_steps == 0 {
eprintln!("walk-forward: disabled (n_folds={}, n_eval_steps={})", cli.n_folds, cli.n_eval_steps);
(all_files, Vec::new())
} else {
anyhow::ensure!(
cli.fold_idx + 1 < cli.n_folds,
"fold_idx {} requires fold_idx + 1 < n_folds ({}); the last block must be the eval window",
cli.fold_idx,
cli.n_folds
);
let n_files = all_files.len();
let block_size = n_files / cli.n_folds;
anyhow::ensure!(
block_size >= 1,
"not enough MBP-10 files ({}) to split into n_folds={} (need ≥ n_folds files)",
n_files,
cli.n_folds
);
// Train: blocks 0..=fold_idx, Eval: block fold_idx+1.
let train_end = (cli.fold_idx + 1) * block_size;
let eval_end = (cli.fold_idx + 2) * block_size;
let train: Vec<_> = all_files[..train_end].to_vec();
let eval: Vec<_> = all_files[train_end..eval_end.min(n_files)].to_vec();
eprintln!(
"walk-forward fold {}/{}: train={} files ({}..{}), eval={} files ({}..{})",
cli.fold_idx, cli.n_folds, train.len(), 0, train_end,
eval.len(), train_end, eval_end.min(n_files)
);
(train, eval)
};
let multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig =
format!("1:{}", cli.seq_len)
.parse()
.with_context(|| format!("multi-resolution `1:{}`", cli.seq_len))?;
anyhow::ensure!(
multi_resolution.total_positions() == cli.seq_len,
"internal: single-scale 1:{} should produce total_positions={} but got {}",
cli.seq_len,
cli.seq_len,
multi_resolution.total_positions()
);
let loader_cfg = MultiHorizonLoaderConfig {
files: train_files,
predecoded_dir: cli.predecoded_dir.clone(),
multi_resolution: multi_resolution.clone(),
horizons: HORIZONS,
// Soft cap above n_steps × n_backtests × 2 so next_sequence_pair
// never returns Ok(None) mid-run. Each step samples n_backtests
// pairs (one per batch slot) to build the B×K snapshot tensor
// that forward_encoder consumes — per-batch market diversity is
// what gives the gradient-variance reduction promised by
// `pearl_b_size_1_signal_starvation_blocks_q_learning`.
n_max_sequences: cli.n_steps
.saturating_mul(cli.n_backtests.max(1))
.saturating_mul(2)
.max(64),
seed: cli.seed,
inference_only: false,
outcome_label_cost: cli.outcome_label_cost,
instrument_filter: cli.instrument_mode,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
let loader = MultiHorizonLoader::new(&loader_cfg).context("MultiHorizonLoader::new")?;
// ── GPU-resident dataset upload. ────────────────────────────────
// Upload ALL pre-converted snapshots + FRD labels to GPU memory as
// a single contiguous device buffer. Per-step data loading becomes
// pure GPU kernel work (sample + gather) — zero CPU data work.
let gpu_dataset: GpuDataset = loader
.upload_to_gpu(&dev, cli.n_backtests.max(1), cli.seed)
.context("loader.upload_to_gpu")?;
let mut gpu_data_loader = GpuDataLoader::new(&dev, cli.n_backtests.max(1))
.context("GpuDataLoader::new")?;
eprintln!(
"GPU dataset uploaded: {} snapshots, {} files, {:.1} GB",
gpu_dataset.total_snapshots,
gpu_dataset.n_files,
(gpu_dataset.total_snapshots * 216) as f64 / 1e9,
);
// Trainer + LobSim.
let perception_cfg = PerceptionTrainerConfig {
seq_len: cli.seq_len,
n_batch: cli.n_backtests,
..PerceptionTrainerConfig::default()
};
let trainer_cfg = IntegratedTrainerConfig {
perception: perception_cfg,
dqn_seed: cli.seed.wrapping_add(0xDA),
ppo_seed: cli.seed.wrapping_add(0xBE),
per_capacity: cli.per_capacity,
per_seed: cli.seed.wrapping_add(0x9E37_79B9),
};
let mut trainer =
IntegratedTrainer::new(&dev, trainer_cfg).context("IntegratedTrainer::new")?;
let mut sim = LobSimCuda::new(cli.n_backtests, &dev).context("LobSimCuda::new")?;
eprintln!(
"trainer + LobSimCuda initialised: seq_len={} n_backtests={} per_capacity={}",
cli.seq_len, cli.n_backtests, cli.per_capacity
);
let mut summary = AlphaRlTrainSummary {
n_steps_planned: cli.n_steps,
seq_len: cli.seq_len,
n_backtests: cli.n_backtests,
per_capacity: cli.per_capacity,
seed: cli.seed,
nan_abort_step: -1,
..Default::default()
};
let mut last_stats: Option<IntegratedStepStats> = None;
// ── Per-step JSONL diag writer (offloaded to dedicated thread). ──
// Default path: `<out>/diag.jsonl`. The smoke run's failure-mode
// catalogue (NaN abort, controller drift, replay stagnation, reward
// explosion) is detected via post-hoc inspection of this file —
// without it, the run is blind beyond the eprintln every-N-step
// ticks.
//
// JSON construction (~150ms) is offloaded to a writer thread via
// sync_channel(1). The training loop snapshots DiagFrame data
// (~0.1ms memcpy) and sends non-blocking via try_send — if the
// writer is still processing the previous frame, the current frame
// is silently dropped. The training loop NEVER blocks on diag I/O.
let diag_path = cli
.diag_jsonl
.clone()
.unwrap_or_else(|| cli.out.join("diag.jsonl"));
let diag_file = std::fs::File::create(&diag_path)
.with_context(|| format!("create diag jsonl {}", diag_path.display()))?;
let diag = BufWriter::new(diag_file);
eprintln!("per-step diag JSONL: {}", diag_path.display());
let (diag_tx, diag_rx) = mpsc::sync_channel::<DiagFrame>(1);
let writer_handle = std::thread::Builder::new()
.name("diag-writer".into())
.spawn(move || diag_writer_thread(diag_rx, diag))
.context("spawn diag writer thread")?;
// Async non-blocking diagnostic staging: separate CUDA stream +
// double-buffered mapped-pinned memory. ALL per-step device reads
// (ISV, rewards, dones, actions, raw_rewards, trade_duration,
// outcome_ema, position_lots, pyramid_count, unit state x4,
// close_unit_index, frd_logits) use async DtoD copies — zero
// stream syncs on the training stream. The host reads the PREVIOUS
// step's buffer while current step copies run on the diag stream.
let mut diag_staging = DiagStaging::new(&dev, cli.n_backtests)
.context("DiagStaging::new")?;
// Per-step scratch (replaced each step via mapped-pinned helpers).
// ── Training loop. ───────────────────────────────────────────────
// Lightweight running counters for the progress log (main thread).
// Full cumulative stats live in the writer thread; these approximate
// for the stderr progress line only.
let mut pnl_cum_usd: f64 = 0.0;
let mut win_count: u64 = 0;
let mut total_trades: u64 = 0;
let start_step = if let Some(ref ckpt_path) = cli.resume_from {
let resumed_step = trainer.load_checkpoint(&dev, ckpt_path)
.with_context(|| format!("resume from {}", ckpt_path.display()))?;
eprintln!("resumed from checkpoint at step {resumed_step}");
resumed_step as usize
} else {
0
};
let t_start = std::time::Instant::now();
for step in start_step..cli.n_steps {
// GPU-resident data loading: sample_and_gather + gather_next +
// gather_current + gather_frd_labels all run as GPU kernels
// inside step_with_lobsim_gpu. Zero CPU data work per step.
let stats = trainer
.step_with_lobsim_gpu(&mut gpu_data_loader, &gpu_dataset, &mut sim)
.with_context(|| format!("step_with_lobsim_gpu at step {step}"))?;
// Gate G8: NaN abort. Per `feedback_stop_on_anomaly` +
// `feedback_kill_runs_on_anomaly_quickly` the cluster smoke
// tail-watcher kills the workflow on non-zero exit; here we
// signal that with exit code 2 (distinct from clean exit 0
// and panic-derived exit codes).
let non_finite = !stats.l_total.is_finite()
|| !stats.l_bce.is_finite()
|| !stats.l_q.is_finite()
|| !stats.l_pi.is_finite()
|| !stats.l_v.is_finite()
|| !stats.l_aux.is_finite();
if non_finite {
eprintln!(
"G8 NaN ABORT at step {step}: l_bce={} l_q={} l_pi={} l_v={} l_aux={} l_total={}",
stats.l_bce, stats.l_q, stats.l_pi, stats.l_v, stats.l_aux, stats.l_total
);
summary.n_steps_completed = step;
summary.nan_abort_step = step as i64;
summary.completed_clean = false;
write_summary(&cli.out, &summary)?;
process::exit(2);
}
// Event-sync the diag snapshot and swap buffers. Previous step's
// async DtoD copies complete here. Placed AFTER step_with_lobsim
// so the diag copies have had the entire training step to finish —
// the event sync is instant (~0 us). On step 0 this is a no-op
// (event sync on a freshly-created event returns immediately).
diag_staging.sync_and_swap().context("diag sync_and_swap")?;
// Launch async DtoD copies of ALL diag buffers into the current
// staging buffer. Non-blocking on the training stream — the copies
// run on diag_staging's separate stream.
diag_staging
.snapshot_async(
trainer.isv_dev_ptr,
trainer.rewards_d.raw_ptr(),
trainer.dones_d.raw_ptr(),
trainer.actions_d.raw_ptr(),
trainer.raw_rewards_d.raw_ptr(),
trainer.trade_duration_emit_d.raw_ptr(),
trainer.outcome_ema_d.raw_ptr(),
trainer.prev_position_lots_d.raw_ptr(),
trainer.pyramid_units_count_d.raw_ptr(),
trainer.unit_entry_price_d.raw_ptr(),
trainer.unit_entry_step_d.raw_ptr(),
trainer.unit_lots_d.raw_ptr(),
trainer.unit_trail_distance_d.raw_ptr(),
trainer.close_unit_index_d.raw_ptr(),
trainer.frd_logits_d.raw_ptr(),
)
.context("diag snapshot_async")?;
// ── Snapshot diagnostic data into owned DiagFrame (~0.1ms). ──
// Reads from the DiagStaging double-buffer (previous step's
// data, one-step delay). All slices are .to_vec() so the frame
// is fully owned and Send-safe for the writer thread.
let rewards_snapshot = diag_staging.read_rewards();
let dones_snapshot = diag_staging.read_dones();
// Lightweight main-loop counters for the progress log.
let reward_sum: f32 = rewards_snapshot.iter().sum();
let done_count: u32 = dones_snapshot.iter().map(|&d| if d > 0.5 { 1 } else { 0 }).sum();
let current_scale = trainer.read_isv_host(ml_alpha::rl::isv_slots::RL_REWARD_SCALE_INDEX);
for b in 0..cli.n_backtests {
if dones_snapshot[b] > 0.5 {
let pnl_usd = if current_scale > 1e-9 {
rewards_snapshot[b] as f64 / current_scale as f64
} else {
rewards_snapshot[b] as f64
};
pnl_cum_usd += pnl_usd;
total_trades += 1;
if pnl_usd > 0.0 { win_count += 1; }
}
}
let win_rate = if total_trades > 0 { win_count as f64 / total_trades as f64 } else { 0.0 };
let actions_raw = diag_staging.read_actions_raw();
let frame = DiagFrame {
step,
elapsed_s: t_start.elapsed().as_secs_f32(),
stats: stats.clone(),
replay_len: trainer.gpu_replay.capacity.min(step + 1),
last_k_updates: trainer.last_k_updates,
n_backtests: cli.n_backtests,
rewards: rewards_snapshot.to_vec(),
dones: dones_snapshot.to_vec(),
actions: actions_raw.iter().map(|f| f.to_bits() as i32).collect(),
raw_rewards: diag_staging.read_raw_rewards().to_vec(),
trade_duration: diag_staging.read_trade_duration().to_vec(),
outcome_ema: diag_staging.read_outcome_ema().to_vec(),
position_lots: diag_staging.read_position_lots(),
pyramid_count: diag_staging.read_pyramid_count(),
unit_entry_price: diag_staging.read_unit_entry_price().to_vec(),
unit_entry_step: diag_staging.read_unit_entry_step(),
unit_lots: diag_staging.read_unit_lots(),
unit_trail: diag_staging.read_unit_trail().to_vec(),
close_unit_index: diag_staging.read_close_unit_index(),
frd_logits: diag_staging.read_frd_logits().to_vec(),
isv: diag_staging.read_isv().to_vec(),
};
// Non-blocking send: if writer is still processing the previous
// frame, this frame is silently dropped. The training loop NEVER
// blocks on diag I/O.
let _ = diag_tx.try_send(frame);
if cli.checkpoint_every > 0 && step > 0 && step % cli.checkpoint_every == 0 {
let ckpt_path = cli.out.join(format!("checkpoint-{step}.bin"));
trainer.save_checkpoint(&ckpt_path, step as u64)
.with_context(|| format!("checkpoint at step {step}"))?;
eprintln!("checkpoint saved: {}", ckpt_path.display());
let old_step = step.saturating_sub(cli.checkpoint_every * 2);
if old_step > 0 {
let old_path = cli.out.join(format!("checkpoint-{old_step}.bin"));
let _ = std::fs::remove_file(&old_path);
}
}
if step % cli.log_every == 0 || step == cli.n_steps - 1 {
let isv = trainer.isv_host_slice();
let elapsed = t_start.elapsed().as_secs_f32();
let sps = if elapsed > 0.0 { (step + 1) as f32 / elapsed } else { 0.0 };
eprintln!(
"step {:>6}/{}: l_q={:.4} l_pi={:.4} l_v={:.4} l_total={:.4} \
γ={:.4} ε={:.4} per_α={:.4} scale={:.4} \
replay={} dones={} rew_sum={:.3} sps={:.0} pnl=${:.0} wr={:.2} elapsed={:.1}s",
step,
cli.n_steps,
stats.l_q,
stats.l_pi,
stats.l_v,
stats.l_total,
isv[RL_GAMMA_INDEX],
isv[RL_PPO_CLIP_INDEX],
isv[RL_PER_ALPHA_INDEX],
isv[RL_REWARD_SCALE_INDEX],
trainer.gpu_replay.capacity.min(step + 1),
done_count,
reward_sum,
sps,
pnl_cum_usd,
win_rate,
elapsed,
);
}
last_stats = Some(stats);
summary.n_steps_completed = step + 1;
}
// Close the diag channel and join the writer thread to ensure all
// pending frames are flushed to disk before proceeding.
drop(diag_tx);
if let Err(e) = writer_handle.join() {
eprintln!("diag writer thread panicked: {e:?}");
}
// ── Eval phase (walk-forward G8). ────────────────────────────────
// After the train phase, run n_eval_steps additional step_with_lobsim
// calls on the held-out eval files. Trade records accumulated by
// LobSim during eval are isolated from train-phase records via the
// `head_before_eval` checkpoint — eval records are sliced as
// `all_records[head_before_eval..]` post-eval.
//
// Note: this is NOT pure-eval mode — step_with_lobsim still runs
// its learning machinery (Adam steps, PER updates). At b_size=1
// the per-step learning effect is small relative to the policy
// already accumulated in train; the eval profit_factor approximates
// the train-end policy's OOS performance. True pure-eval (forward
// only, no backward) is a follow-up architectural change.
if cli.n_eval_steps > 0 && !eval_files.is_empty() {
let head_before_eval = sim
.read_total_trade_count()
.context("read trade count pre-eval")?;
eprintln!(
"── eval phase: {} steps on {} held-out files (trade-record checkpoint head={}) ──",
cli.n_eval_steps, eval_files.len(), head_before_eval
);
let eval_loader_cfg = MultiHorizonLoaderConfig {
files: eval_files.clone(),
predecoded_dir: cli.predecoded_dir.clone(),
multi_resolution,
horizons: HORIZONS,
n_max_sequences: cli.n_eval_steps
.saturating_mul(cli.n_backtests.max(1))
.saturating_mul(2)
.max(64),
// Different seed for eval so sequence sampling is independent
// of the train seed (deterministic given the eval seed).
seed: cli.seed.wrapping_add(0xE7AE),
inference_only: false,
outcome_label_cost: cli.outcome_label_cost,
instrument_filter: cli.instrument_mode,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
let eval_loader = MultiHorizonLoader::new(&eval_loader_cfg)
.context("MultiHorizonLoader::new (eval)")?;
// Upload eval dataset to GPU (separate from train dataset).
let eval_gpu_dataset = eval_loader
.upload_to_gpu(&dev, cli.n_backtests.max(1), cli.seed.wrapping_add(0xE7AE))
.context("eval loader.upload_to_gpu")?;
let mut eval_gpu_loader = GpuDataLoader::new(&dev, cli.n_backtests.max(1))
.context("GpuDataLoader::new (eval)")?;
for eval_step in 0..cli.n_eval_steps {
let stats = trainer
.step_with_lobsim_gpu(&mut eval_gpu_loader, &eval_gpu_dataset, &mut sim)
.with_context(|| format!("step_with_lobsim_gpu eval step {eval_step}"))?;
if !stats.l_total.is_finite() {
eprintln!("G8 NaN ABORT during eval at step {eval_step}");
process::exit(2);
}
if eval_step % cli.log_every == 0 || eval_step == cli.n_eval_steps - 1 {
eprintln!(
"eval {:>5}/{}: l_total={:.4} elapsed={:.1}s",
eval_step, cli.n_eval_steps, stats.l_total,
t_start.elapsed().as_secs_f32()
);
}
}
// Drain trade records, slice to eval-only, compute summary.
let all_records = sim
.read_trade_records(0)
.context("read trade records post-eval")?;
let head_before_usize = head_before_eval as usize;
let eval_records: Vec<_> = if all_records.len() > head_before_usize {
all_records[head_before_usize..].to_vec()
} else {
// Trade log wrapped past TRADE_LOG_CAP — use what we have.
// For smoke (b_size=1, ≤200 trades) this branch never fires.
eprintln!(
"warning: trade log wrapped — head_before={} but only {} records readable",
head_before_usize, all_records.len()
);
all_records
};
// Synthesise a pnl_curve from per-trade cumulative PnL for
// compute_summary's max_drawdown calc.
let mut pnl_curve = Vec::with_capacity(eval_records.len());
let mut cum = 0.0f32;
for r in &eval_records {
cum += (r.realised_pnl_usd_fp as f32) / 100.0;
pnl_curve.push(cum);
}
let eval_summary =
ml_backtesting::artifacts::compute_summary(&eval_records, &pnl_curve);
eprintln!(
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} max_dd_usd={:.2} win_rate={:.3}",
eval_summary.n_trades,
eval_summary.total_pnl_usd,
eval_summary.profit_factor,
eval_summary.sharpe_ann,
eval_summary.max_drawdown_usd,
eval_summary.win_rate,
);
let eval_summary_path = cli.out.join("eval_summary.json");
let f = std::fs::File::create(&eval_summary_path)
.with_context(|| format!("create {}", eval_summary_path.display()))?;
serde_json::to_writer_pretty(f, &eval_summary)
.with_context(|| format!("write {}", eval_summary_path.display()))?;
eprintln!("eval summary written: {}", eval_summary_path.display());
}
// ── Compose + emit summary. ──────────────────────────────────────
if let Some(s) = last_stats {
summary.final_l_bce = s.l_bce;
summary.final_l_q = s.l_q;
summary.final_l_pi = s.l_pi;
summary.final_l_v = s.l_v;
summary.final_l_aux = s.l_aux;
summary.final_l_total = s.l_total;
}
summary.final_replay_len = trainer.gpu_replay.capacity.min(cli.n_steps);
summary.completed_clean = summary.n_steps_completed == summary.n_steps_planned;
write_summary(&cli.out, &summary)?;
eprintln!(
"alpha_rl_train complete: {} / {} steps in {:.1}s (replay len = {})",
summary.n_steps_completed,
summary.n_steps_planned,
t_start.elapsed().as_secs_f32(),
summary.final_replay_len
);
Ok(())
}
fn write_summary(out_dir: &std::path::Path, summary: &AlphaRlTrainSummary) -> Result<()> {
let path = out_dir.join("alpha_rl_train_summary.json");
let f = std::fs::File::create(&path)
.with_context(|| format!("create {}", path.display()))?;
serde_json::to_writer_pretty(f, summary)
.with_context(|| format!("write {}", path.display()))?;
eprintln!("summary written: {}", path.display());
Ok(())
}