fix(alpha): MBP-10 parser full-levels copy + fit_poisson L2 regularization

Two carried-over limitations from Phase E.0 / E.1 fixed and verified.

1. MBP-10 parser bug fix (`parse_mbp10_streaming` + `parse_mbp10_file`)

   The DBN crate's `Mbp10Msg` carries the FULL post-update top-10 book
   in `levels: [BidAskPair; 10]` per message — not just the single
   update event's price/size. Previously the parser only called
   `update_level(0, ...)` with the update event's fields, leaving
   `current_snapshot.levels[1..10]` at default-empty. Downstream:
     - OFI calculator reading L2-L5 got zeros → produced wrong OFI
       features (the canonical Phase 1c/1d 81-dim feature stack has
       multi-level OFI as features 0..5; with the bug these were
       constant zero).
     - microprice (`snapshot.levels[1]`) got zeros.
     - FillModel L2/L3 fit observations got zeros, so L2/L3
       coefficients were undefined (we worked around by replicating
       L1 with attenuated intercept).

   Fix: after `update_level(0, ...)`, copy fields from
   `mbp10.levels[lvl]` into `current_snapshot.levels[lvl]` for `lvl
   in 1..max_lvl`. Field-by-field copy preserves the existing scale
   convention (raw 1e9 fixed-point i64). Applied to both streaming
   and async file-parse code paths.

   Comment "For simplicity, store all updates in level 0 / A full
   implementation would maintain proper level ordering" removed.

2. fit_poisson L2 regularization

   New `fit_poisson_l2(features, observed, max_iters, lr, l2_lambda)`
   API (the old `fit_poisson` delegates with l2_lambda=0). L2 penalty
   applies to slope coefficients β[1..5] but NOT to intercept β[0]
   (penalizing the intercept biases toward p≈0.5 for all-zero-feature
   samples, breaking the recovery test). Per-iteration update:

     β[0] -= lr · grad[0] / n               (intercept)
     β[k] -= lr · (grad[k] / n + λ · β[k])  (slope, k ∈ 1..5)

   Canonical motivation: on real 5.2M-trade ES.FUT data the
   unregularized fitter converged to β_spread ≈ -40 (Task 5c commit
   12151ccf6), producing near-zero limit fill probability at typical
   spreads despite empirical fill rate ~70%. With l2_lambda=0.01 the
   slope shrinks modestly while intercept tracks the empirical rate.
   Default in the calibration binary bumped to 0.01.

   New unit test `fit_poisson_l2_shrinks_slope_on_pathological_outlier`
   constructs 990 typical samples + 10 wide-spread outliers and
   verifies `|β_spread|` with L2 < `|β_spread|` without L2. Passes.

3. Cascade re-run verifies the fix is verdict-robust:

     New fit (with L2 + parser fix, 500K snapshots):
       BID L1: β_0=-0.24  β_spread=-1.87  β_imbal=-0.10  β_ofi=-0.006  β_logτ=-0.30
       ASK L1: β_0=+0.21  β_spread=-36.41 β_imbal=+0.19  β_ofi=+0.81   β_logτ=+0.22
       (β_spread on ask still large but β_0 sane; cloglog model
       fundamentally mis-fits the binary tight-spread / wide-spread regime.)

     New baseline (with new fill model):
       mean = -5191.53   (vs old -5185.13)
       std  =  4963.62   (vs old  4952.85)
       Negligible drift, env dynamics essentially unchanged.

     H=6000 smoke re-run (same alpha cache, new fill model + parser):
       Q_SPREAD_EMA         = 29.59   (was 35.44)
       ACTION_ENTROPY_EMA   = 2.00    (was 2.00)
       RETURN_VS_RANDOM_EMA = +1.001σ (was +1.003σ)
       EARLY_Q_MOVEMENT_EMA = 0.130   (was 0.130)
       Overall: PASS (was PASS)

   Verdict is ROBUST to the fixes — the fxcache-based smoke is
   insulated from the MBP-10 parser bug (uses synthesized bid/ask
   from mid), and the FillModel quality improvement is minor enough
   that the policy's behaviour is essentially unchanged. The fixes
   matter MORE for production training paths that read MBP-10
   directly (those see the full L2-L10 book now).

Files touched:
  crates/data/src/providers/databento/dbn_parser.rs (parser fix in
    both parse_mbp10_streaming and parse_mbp10_file)
  crates/ml/src/env/fill_model.rs (new fit_poisson_l2 + test)
  crates/ml/examples/alpha_fit_fill_model.rs (--l2-lambda flag)
  crates/ml/examples/alpha_dqn_h600_smoke.rs (updated hardcoded
    baseline values to match the new random baseline run)
  config/ml/alpha_fill_coeffs.json (re-fitted with both fixes)
  config/ml/alpha_random_baseline.json (re-run with new fill model)
  config/ml/alpha_dqn_h6000_smoke.json (verified PASS)

All 8 fill_model tests pass. Build clean across data, ml-alpha, ml.
This commit is contained in:
jgrusewski
2026-05-15 17:20:52 +02:00
parent 79d15b3196
commit 5c0bcb1fdb
7 changed files with 275 additions and 138 deletions

View File

@@ -589,8 +589,12 @@ fn main() -> Result<()> {
// --- ISV buffer (552 floats) with TrainingPersist anchors set ---
let mut isv_host: Vec<f32> = vec![0.0; 552];
isv_host[RANDOM_BASELINE_MEAN_INDEX] = -5185.13; // Task 7c value
isv_host[RANDOM_BASELINE_STD_INDEX] = 4952.85;
// Updated 2026-05-15 with the new FillModel (L2-regularized fit + MBP-10
// parser full-levels-copy fix). Negligible drift from the pre-fix run
// (-5185.13 / 4952.85) — confirms the smoke verdict is robust to these
// bug fixes. See alpha_random_baseline.json for the source numbers.
isv_host[RANDOM_BASELINE_MEAN_INDEX] = -5191.53;
isv_host[RANDOM_BASELINE_STD_INDEX] = 4963.62;
let mut isv_dev = stream.clone_htod(&isv_host).context("upload isv")?;
// Wiener state for the 4 kill-criteria slots: 4 × [sample_var, diff_var, x_lag] = 12 floats.

View File

@@ -54,7 +54,7 @@ use data::providers::databento::{
fn raw_price_to_f32(fixed: i64) -> f32 {
(fixed as f64 * 1e-9) as f32
}
use ml::env::fill_model::{fit_poisson, FillCoeffs, FillFeatures};
use ml::env::fill_model::{fit_poisson_l2, FillCoeffs, FillFeatures};
use ml::features::{filter_front_month, load_trades_sync, DbnTrade};
use ml::trainers::dqn::collect_dbn_files_recursive;
@@ -85,6 +85,13 @@ struct Cli {
/// SGD learning rate for `fit_poisson`.
#[arg(long, default_value_t = 1e-2)]
fit_lr: f32,
/// L2 regularization strength on slope coefficients (intercept is
/// NOT penalized). Default 0.01 — small but non-zero, prevents the
/// β_spread runaway (-40 in the unregularized canonical run) while
/// staying close to the data-fit minimum. Use 0.0 for the
/// (deprecated) unregularized fit.
#[arg(long, default_value_t = 0.01)]
l2_lambda: f32,
/// Optional cap on snapshots processed (for quick smokes).
#[arg(long)]
max_snapshots: Option<usize>,
@@ -103,7 +110,8 @@ fn main() -> Result<()> {
info!(" trades_dir = {}", cli.trades_dir.display());
info!(" window_seconds = {:.1}", cli.window_seconds);
info!(" snapshot_interval = {}", cli.snapshot_interval);
info!(" fit_iters = {}, fit_lr = {}", cli.fit_iters, cli.fit_lr);
info!(" fit_iters = {}, fit_lr = {}, l2_lambda = {}",
cli.fit_iters, cli.fit_lr, cli.l2_lambda);
if let Some(cap) = cli.max_snapshots {
info!(" max_snapshots cap = {}", cap);
}
@@ -269,7 +277,7 @@ fn main() -> Result<()> {
cli.fit_iters,
cli.fit_lr
);
let bid_l1_coeffs = fit_poisson(&bid_feat, &bid_outcomes, cli.fit_iters, cli.fit_lr)
let bid_l1_coeffs = fit_poisson_l2(&bid_feat, &bid_outcomes, cli.fit_iters, cli.fit_lr, cli.l2_lambda)
.map_err(|e| anyhow::anyhow!("fit bid L1: {}", e))?;
info!(
"Fitting ask L1 (n={}, iters={}, lr={})...",
@@ -277,7 +285,7 @@ fn main() -> Result<()> {
cli.fit_iters,
cli.fit_lr
);
let ask_l1_coeffs = fit_poisson(&ask_feat, &ask_outcomes, cli.fit_iters, cli.fit_lr)
let ask_l1_coeffs = fit_poisson_l2(&ask_feat, &ask_outcomes, cli.fit_iters, cli.fit_lr, cli.l2_lambda)
.map_err(|e| anyhow::anyhow!("fit ask L1: {}", e))?;
// --- 4. Synthesize L2/L3 via β_0 attenuation ---
@@ -309,6 +317,7 @@ fn main() -> Result<()> {
"snapshot_interval": cli.snapshot_interval,
"fit_iters": cli.fit_iters,
"fit_lr": cli.fit_lr,
"l2_lambda": cli.l2_lambda,
"empirical_bid_l1_rate": bid_rate,
"empirical_ask_l1_rate": ask_rate,
"l1_only_limitation": true,

View File

@@ -116,9 +116,42 @@ pub fn fit_poisson(
observed_fills: &[bool],
max_iters: usize,
lr: f32,
) -> Result<FillCoeffs, &'static str> {
fit_poisson_l2(features, observed_fills, max_iters, lr, 0.0)
}
/// `fit_poisson` with L2 regularization on the slope coefficients
/// `β[1..5]`. The intercept `β[0]` is NOT penalized (otherwise the
/// fitter biases toward p ≈ 0.5 instead of the true empirical rate
/// for all-zero-feature samples — breaks the recovery test).
///
/// Without regularization, on real 5.2M-trade ES.FUT data the fitter
/// converged to `β_spread ≈ 40` (canonical Task 5c observation,
/// commit `12151ccf6`). The aggressive negative slope on spread
/// produced near-zero limit fill probability at typical spreads
/// (0.5 bps), even though empirical fill rate at typical spreads was
/// ~70%. Result: the FillModel under-fills in production by ~30 pp.
///
/// Per-sample gradient update with L2 (slope only):
///
/// β[k] = lr · [grad[k] / n + l2_lambda · β[k]], k ∈ {1..5}
/// β[0] = lr · grad[0] / n (intercept, no L2)
///
/// Typical `l2_lambda ∈ [0.001, 0.1]`. With 0.0 this collapses to the
/// original `fit_poisson` behaviour (which the recovery test still
/// validates).
pub fn fit_poisson_l2(
features: &[FillFeatures],
observed_fills: &[bool],
max_iters: usize,
lr: f32,
l2_lambda: f32,
) -> Result<FillCoeffs, &'static str> {
if features.len() != observed_fills.len() || features.is_empty() {
return Err("fit_poisson: length mismatch or empty");
return Err("fit_poisson_l2: length mismatch or empty");
}
if l2_lambda < 0.0 {
return Err("fit_poisson_l2: l2_lambda must be non-negative");
}
const P_FLOOR: f32 = 1e-7;
let mut beta = [0.0_f32; 5];
@@ -138,15 +171,17 @@ pub fn fit_poisson(
let p = 1.0 - (-mu).exp();
let p_safe = p.max(P_FLOOR);
let target = if y { 1.0 } else { 0.0 };
// Cloglog gradient: (p - y) * (μ/p) * x
let link_deriv = mu / p_safe;
let scale = (p - target) * link_deriv;
for k in 0..5 {
grad[k] += scale * x[k];
}
}
for k in 0..5 {
beta[k] -= lr * grad[k] / n;
// Intercept (k=0): no L2 penalty.
beta[0] -= lr * grad[0] / n;
// Slopes (k=1..5): MSE gradient + L2 penalty.
for k in 1..5 {
beta[k] -= lr * (grad[k] / n + l2_lambda * beta[k]);
}
}
Ok(FillCoeffs { beta })
@@ -254,6 +289,54 @@ mod tests {
}
}
#[test]
fn fit_poisson_l2_shrinks_slope_on_pathological_outlier() {
// 990 samples with no signal (p=0.4) + 10 outliers with extreme
// feature values and p=1 fills → without L2, the fitter pushes
// β_spread to a huge negative value to fit the 10 outliers,
// sacrificing fit quality on the 990 typical samples.
//
// This mimics the canonical Phase E.0 Task 5c failure mode where
// β_spread → 40 on real data because the fitter was over-fitting
// wide-spread outliers.
let n_typ = 990;
let n_out = 10;
let mut features: Vec<FillFeatures> = Vec::new();
let mut observed: Vec<bool> = Vec::new();
for i in 0..n_typ {
features.push(FillFeatures {
spread_bps: 0.5, // typical
l1_imbalance: 0.5,
ofi_sum_5: 0.0,
time_since_trade_s: 0.0,
});
observed.push((i * 401 % 1000) < 400); // 40% fill rate
}
for _ in 0..n_out {
features.push(FillFeatures {
spread_bps: 10.0, // 20× typical
l1_imbalance: 0.5,
ofi_sum_5: 0.0,
time_since_trade_s: 0.0,
});
observed.push(true); // 100% fill on outliers
}
// No L2: slope can drift far to fit outliers.
let no_l2 = fit_poisson_l2(&features, &observed, 1000, 1e-2, 0.0).expect("fit");
// With L2: slope shrinks toward zero, intercept absorbs more of the
// signal.
let with_l2 = fit_poisson_l2(&features, &observed, 1000, 1e-2, 0.1).expect("fit");
// The L2-regularized β_spread should be CLOSER TO ZERO than the
// unregularized one (the bug is β_spread → very negative). Direction-
// agnostic assertion via absolute value.
assert!(
with_l2.beta[1].abs() < no_l2.beta[1].abs(),
"L2 should shrink β_spread: no_l2={} with_l2={}",
no_l2.beta[1],
with_l2.beta[1]
);
}
#[test]
fn fit_rejects_empty_and_mismatched_inputs() {
assert!(fit_poisson(&[], &[], 100, 1e-2).is_err());