Files
foxhunt/crates/ml/examples/alpha_fit_fill_model.rs
jgrusewski 5c0bcb1fdb 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.
2026-05-15 17:20:52 +02:00

378 lines
15 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.
//! Phase E.0 Task 5 — fit FillModel coefficients from historical MBP-10 + trade tape.
//!
//! ## What this does
//!
//! Streams MBP-10 snapshots concurrently with the time-sorted trade tape. For
//! each snapshot, determines whether a hypothetical posted limit at the L1
//! bid (resp. ask) would have been filled within `--window-seconds` (default
//! 60s). Accumulates (FillFeatures, fill_outcome) pairs per side, then calls
//! `fit_poisson` from `ml::env::fill_model` (cloglog Bernoulli likelihood, see
//! [`pearl_glm_fitter_link_must_match_inference`]). Writes the 6 fitted
//! coefficient sets to JSON.
//!
//! ## L1-only limitation (2026-05-15)
//!
//! `DbnParser::parse_mbp10_streaming` currently tracks only the L1 best
//! bid/ask via `update_level(0, ...)` and **ignores** the `levels:
//! [BidAskPair; 10]` array carried by every `Mbp10Msg`. The full top-10 is
//! in the message but is not copied. Until that parser is fixed to copy
//! the full top-10, this binary fits ONLY the L1 distribution and
//! replicates L1 coefficients across the L2/L3 slots with an attenuated
//! intercept (`β_0 -= ln(L+1)`) to keep monotonically lower fill rates at
//! deeper levels.
//!
//! ## Run
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run --release --example alpha_fit_fill_model -- \
//! --mbp10-dir /home/jgrusewski/Work/foxhunt/test_data/futures-baseline-mbp10/ES.FUT \
//! --trades-dir /home/jgrusewski/Work/foxhunt/test_data/futures-baseline-trades/ES.FUT \
//! --window-seconds 60 \
//! --out-path alpha_fill_coeffs.json
//! ```
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use tracing::{info, warn};
use data::providers::databento::{
dbn_parser::DbnParser,
mbp10::Mbp10Snapshot,
};
/// Convert raw `Mbp10Msg.price` (i64 fixed-point at 1e9 scale per the
/// databento DBN format) to f32 in price units. Bypasses
/// `BidAskPair::price_to_f64`, which divides by 1e12 (different convention)
/// and would give bid/ask values 1000× too small. The parser stores raw
/// `mbp10.price` directly into `bid_px`/`ask_px` without re-scaling, so
/// readers must apply the 1e-9 themselves.
#[inline]
fn raw_price_to_f32(fixed: i64) -> f32 {
(fixed as f64 * 1e-9) as f32
}
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;
#[derive(Debug, Parser)]
#[command(
name = "alpha_fit_fill_model",
about = "Phase E.0 Task 5 — fit FillModel coefficients from historical MBP-10 + trades"
)]
struct Cli {
/// Directory containing MBP-10 `.dbn[.zst]` files.
#[arg(long)]
mbp10_dir: PathBuf,
/// Directory containing trade `.dbn[.zst]` files.
#[arg(long)]
trades_dir: PathBuf,
/// Forward-looking fill window in seconds.
#[arg(long, default_value_t = 60.0)]
window_seconds: f32,
/// Output JSON path.
#[arg(long, default_value = "config/ml/alpha_fill_coeffs.json")]
out_path: PathBuf,
/// MBP-10 events between snapshot emissions (denser = larger sample).
#[arg(long, default_value_t = 10)]
snapshot_interval: usize,
/// SGD iterations for `fit_poisson`.
#[arg(long, default_value_t = 1000)]
fit_iters: usize,
/// 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>,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
info!("Phase E.0 Task 5 — FillModel calibration starting");
info!(" mbp10_dir = {}", cli.mbp10_dir.display());
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 = {}, l2_lambda = {}",
cli.fit_iters, cli.fit_lr, cli.l2_lambda);
if let Some(cap) = cli.max_snapshots {
info!(" max_snapshots cap = {}", cap);
}
// --- 1. Load trades, front-month-filtered per file, time-sorted ---
let trade_files = collect_dbn_files_recursive(&cli.trades_dir);
if trade_files.is_empty() {
anyhow::bail!("no .dbn[.zst] files found in {:?}", cli.trades_dir);
}
info!("Found {} trade file(s)", trade_files.len());
let mut trades: Vec<DbnTrade> = Vec::new();
for file in &trade_files {
let raw = load_trades_sync(file)
.with_context(|| format!("load_trades_sync({})", file.display()))?;
let filtered = filter_front_month(&raw);
info!(
" {} -> {} raw, {} front-month",
file.file_name().unwrap_or_default().to_string_lossy(),
raw.len(),
filtered.len()
);
trades.extend(filtered);
}
trades.sort_by_key(|t| t.timestamp);
info!("Total trades loaded (front-month, time-sorted): {}", trades.len());
if trades.is_empty() {
anyhow::bail!("trades vec empty after filtering — check --trades-dir");
}
// --- 2. Stream snapshots, accumulate per-(side, L1) observations ---
let window_ns: u64 = (cli.window_seconds * 1_000_000_000.0) as u64;
let parser = DbnParser::new().context("DbnParser::new")?;
let mbp10_files = collect_dbn_files_recursive(&cli.mbp10_dir);
if mbp10_files.is_empty() {
anyhow::bail!("no MBP-10 .dbn[.zst] files found in {:?}", cli.mbp10_dir);
}
info!("Found {} MBP-10 file(s)", mbp10_files.len());
let mut bid_feat: Vec<FillFeatures> = Vec::new();
let mut bid_outcomes: Vec<bool> = Vec::new();
let mut ask_feat: Vec<FillFeatures> = Vec::new();
let mut ask_outcomes: Vec<bool> = Vec::new();
let mut n_processed: usize = 0;
let mut n_degenerate_skipped: usize = 0;
let limit = cli.max_snapshots.unwrap_or(usize::MAX);
'files: for file in &mbp10_files {
let mut hit_limit = false;
let result = parser.parse_mbp10_streaming(
file,
cli.snapshot_interval,
|snap: &Mbp10Snapshot| {
if n_processed >= limit {
hit_limit = true;
return;
}
if snap.levels.is_empty() {
return;
}
let l1 = &snap.levels[0];
let bid_l1 = raw_price_to_f32(l1.bid_px);
let ask_l1 = raw_price_to_f32(l1.ask_px);
if bid_l1 <= 0.0 || ask_l1 <= 0.0 || bid_l1 >= ask_l1 {
// Skip pre-quote / one-sided / crossed snapshots — the parser
// updates one side per message so the early file region can
// legitimately have a stale opposite side.
n_degenerate_skipped += 1;
return;
}
let mid = 0.5 * (bid_l1 + ask_l1);
let spread = ask_l1 - bid_l1;
let spread_bps = 10_000.0 * spread / mid;
let bid_sz = l1.bid_sz as f32;
let ask_sz = l1.ask_sz as f32;
let l1_imbalance = if bid_sz + ask_sz > 0.0 {
bid_sz / (bid_sz + ask_sz)
} else {
0.5
};
// OFI proxy uses only L1 because L2-L9 aren't populated by the
// current streaming parser. Documented limitation; OK because
// we're fitting L1 distributions only.
let ofi_sum_5 = bid_sz - ask_sz;
let ts = snap.timestamp;
let window_end = ts.saturating_add(window_ns);
let i_end = trades.partition_point(|t| t.timestamp < window_end);
let i_start = trades.partition_point(|t| t.timestamp <= ts);
let window_trades: &[DbnTrade] = &trades[i_start..i_end];
// time_since_trade: prior trade strictly before snapshot
let tau_s = if i_start > 0 {
let prior_ts = trades[i_start - 1].timestamp;
if ts >= prior_ts {
((ts - prior_ts) as f32) * 1e-9
} else {
0.0
}
} else {
0.0
};
let feat = FillFeatures {
spread_bps,
l1_imbalance,
ofi_sum_5,
time_since_trade_s: tau_s,
};
// L1 bid fill: any sell-aggressor (is_buy=false) at price ≤ bid_l1
// ⇒ a posted-bid limit would have been hit by an aggressive seller
// L1 ask fill: any buy-aggressor (is_buy=true) at price ≥ ask_l1
let bid_fill = window_trades
.iter()
.any(|t| !t.is_buy && (t.price as f32) <= bid_l1);
let ask_fill = window_trades
.iter()
.any(|t| t.is_buy && (t.price as f32) >= ask_l1);
bid_feat.push(feat);
bid_outcomes.push(bid_fill);
ask_feat.push(feat);
ask_outcomes.push(ask_fill);
n_processed += 1;
if n_processed % 100_000 == 0 {
info!(" processed {} snapshots", n_processed);
}
},
);
match result {
Ok(snap_count) => info!(
" {} → {} streamed snapshots from parser",
file.file_name().unwrap_or_default().to_string_lossy(),
snap_count
),
Err(e) => warn!("parser error on {}: {}", file.display(), e),
}
if hit_limit {
info!("max_snapshots cap reached; stopping iteration");
break 'files;
}
}
info!(
"Snapshots accumulated: {}, degenerate-skipped: {}",
n_processed, n_degenerate_skipped
);
if n_processed == 0 {
anyhow::bail!("No usable snapshots accumulated — check data paths and parser output");
}
let bid_rate = bid_outcomes.iter().filter(|b| **b).count() as f64
/ bid_outcomes.len().max(1) as f64;
let ask_rate = ask_outcomes.iter().filter(|b| **b).count() as f64
/ ask_outcomes.len().max(1) as f64;
info!(
"Empirical fill rates within {:.0}s window: bid_l1 = {:.4}, ask_l1 = {:.4}",
cli.window_seconds, bid_rate, ask_rate
);
// --- 3. Fit cloglog Bernoulli (L1 only) ---
info!(
"Fitting bid L1 (n={}, iters={}, lr={})...",
bid_feat.len(),
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={})...",
ask_feat.len(),
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 ---
//
// λ_L = λ_L1 / (L+1) corresponds to β_0 -= ln(L+1):
// L1 (slot 0): β_0 unchanged
// L2 (slot 1): β_0 -= ln(2) ≈ -0.693
// L3 (slot 2): β_0 -= ln(3) ≈ -1.099
//
// This gives a monotonically lower expected fill rate at deeper levels,
// which matches qualitative reality even though the L2/L3 SLOPES are
// L1-equivalent (a documented coarse approximation).
let make_levels = |c: &FillCoeffs| -> [FillCoeffs; 3] {
let mut levels = [*c, *c, *c];
levels[1].beta[0] -= 2.0_f32.ln();
levels[2].beta[0] -= 3.0_f32.ln();
levels
};
let bid_levels = make_levels(&bid_l1_coeffs);
let ask_levels = make_levels(&ask_l1_coeffs);
// --- 5. Write JSON ---
let json = serde_json::json!({
"phase": "E.0 Task 5",
"n_snapshots": n_processed,
"n_trades": trades.len(),
"n_degenerate_skipped": n_degenerate_skipped,
"window_seconds": cli.window_seconds,
"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,
"l1_only_limitation_reason":
"parse_mbp10_streaming ignores Mbp10Msg.levels[1..10]; \
L2/L3 coefficients are L1 with attenuated β_0 (-ln(L+1))",
"bid_coeffs": [
bid_levels[0].beta.to_vec(),
bid_levels[1].beta.to_vec(),
bid_levels[2].beta.to_vec(),
],
"ask_coeffs": [
ask_levels[0].beta.to_vec(),
ask_levels[1].beta.to_vec(),
ask_levels[2].beta.to_vec(),
],
});
let mut f = File::create(&cli.out_path).context("create output file")?;
write!(f, "{}", serde_json::to_string_pretty(&json)?)?;
info!("Wrote coefficients to {}", cli.out_path.display());
// --- 6. Sanity-check signs + report ---
info!("=== Fitted L1 coefficient summary ===");
info!(
"BID L1: β_0={:+.4} β_spread={:+.4} β_imbal={:+.4} β_ofi={:+.4} β_logτ={:+.4}",
bid_l1_coeffs.beta[0],
bid_l1_coeffs.beta[1],
bid_l1_coeffs.beta[2],
bid_l1_coeffs.beta[3],
bid_l1_coeffs.beta[4]
);
info!(
"ASK L1: β_0={:+.4} β_spread={:+.4} β_imbal={:+.4} β_ofi={:+.4} β_logτ={:+.4}",
ask_l1_coeffs.beta[0],
ask_l1_coeffs.beta[1],
ask_l1_coeffs.beta[2],
ask_l1_coeffs.beta[3],
ask_l1_coeffs.beta[4]
);
// Wider spread should reduce fill probability — β_spread should be negative
// (or at worst slightly positive if the spread-fill correlation is masked
// by another regime variable).
if bid_l1_coeffs.beta[1] > 0.0 {
warn!(
"bid β_spread is positive ({:.4}) — counter-intuitive; expected negative",
bid_l1_coeffs.beta[1]
);
}
if ask_l1_coeffs.beta[1] > 0.0 {
warn!(
"ask β_spread is positive ({:.4}) — counter-intuitive",
ask_l1_coeffs.beta[1]
);
}
Ok(())
}