From a5de50503f76ad46060d536f135d53ff44bb897a Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 15 May 2026 12:27:57 +0200 Subject: [PATCH] feat(alpha): Poisson regression fill model scaffold (coeffs fitted in Task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase E Task 4. Medium-tier fill simulator per the design memo. For each (level ∈ {L1,L2,L3}, side ∈ {Bid,Ask}): λ(features) = exp(β · [1, spread_bps, L1_imb, OFI_5, log(τ+1)]) Per-snapshot Bernoulli fill probability for a posted limit order: p = 1 − exp(−λ) Market orders fill immediately at the opposite-side L1 quote (no slippage modeled at medium tier). Closing actions (Side::None) return λ=0 — they are handled separately in the env. Scaffolding only. Task 5 fits the 30 coefficients (6 distributions × 5 features) from the 5.2M-trade historical tape. The skeleton constructor uses β=0 → λ=1 → p≈0.632, a stable sanity default for early smokes. Numeric guard: `linear.exp().min(50.0)` caps λ to prevent f32 overflow under outlier features before fitting lands. Fitted models should stay well below this cap in practice. 4 unit tests: - skeleton_has_uniform_fill_prob (β=0 → p≈0.632 within 0.01) - fill_prob_bounded_under_outlier_features - lambda_cap_prevents_f32_overflow (β=100 outlier path) - side_none_returns_zero_rate `cargo test -p ml --lib env::fill_model`: 4 passed. --- crates/ml/src/env/fill_model.rs | 159 ++++++++++++++++++++++++++++++++ crates/ml/src/env/mod.rs | 6 +- 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 crates/ml/src/env/fill_model.rs diff --git a/crates/ml/src/env/fill_model.rs b/crates/ml/src/env/fill_model.rs new file mode 100644 index 000000000..675beab38 --- /dev/null +++ b/crates/ml/src/env/fill_model.rs @@ -0,0 +1,159 @@ +//! Poisson-regression fill simulator (medium tier per the design memo). +//! +//! For each level `l ∈ {L1, L2, L3}` and side, we fit a Poisson regression: +//! +//! λ_l(features) = exp(β_l · features) +//! +//! where features = [1, spread_bps, L1_imbalance, OFI_sum_5, log(time_since_trade + 1)]. +//! At backtest time, the per-snapshot fill *probability* (Bernoulli) for a +//! posted limit order at level `l` is `1 − exp(−λ_l(features))` (the standard +//! Poisson-to-Bernoulli conversion for a single time bucket). For market +//! orders, fill is always immediate; the *price* is the L1 quote on the +//! opposite side (no slippage modeled at medium tier). +//! +//! The 5 coefficients per (level, side) = 6 distributions × 5 = 30 floats +//! total. Tiny. Fits in registers. +//! +//! This file lands SCAFFOLDING only. Coefficient fitting lands in Task 5 +//! per the Phase E plan. + +use crate::env::action_space::Side; + +/// One linear-predictor coefficient vector for the (level, side) pair. +#[derive(Debug, Clone, Copy)] +pub struct FillCoeffs { + /// β_0 + β_1·spread + β_2·imbalance + β_3·ofi_5 + β_4·log(tau+1) + pub beta: [f32; 5], +} + +/// Six distributions: (level=L1/L2/L3) × (side=BID/ASK). +/// +/// A passive Buy at the bid uses `bid_coeffs[level]` (we want to be filled +/// by an aggressor crossing down). A passive Sell at the ask uses +/// `ask_coeffs[level]`. +#[derive(Debug, Clone)] +pub struct FillModel { + pub bid_coeffs: [FillCoeffs; 3], + pub ask_coeffs: [FillCoeffs; 3], +} + +#[derive(Debug, Clone, Copy)] +pub struct FillFeatures { + pub spread_bps: f32, + pub l1_imbalance: f32, + pub ofi_sum_5: f32, + pub time_since_trade_s: f32, +} + +impl FillModel { + /// Untrained-skeleton constructor. + /// + /// β=0 → λ=exp(0)=1 → fill_prob = 1 − exp(−1) ≈ 0.632. This is a + /// reasonable sanity default before Task 5 fits real coefficients. + pub fn skeleton() -> Self { + let zero = FillCoeffs { beta: [0.0; 5] }; + Self { + bid_coeffs: [zero; 3], + ask_coeffs: [zero; 3], + } + } + + /// Fill-rate λ for a (side, level) at the given features. + /// + /// Numerical guard: the linear predictor is capped via `.exp().min(50.0)` + /// so outlier features cannot overflow f32 before fitting lands. A fitted + /// model should never approach this saturation in practice. + pub fn lambda(&self, side: Side, level: usize, feat: &FillFeatures) -> f32 { + let coeffs = match side { + Side::Buy => &self.bid_coeffs, + Side::Sell => &self.ask_coeffs, + Side::None => return 0.0, // closing actions handled separately + }; + let c = &coeffs[level.min(2)].beta; + let x = [ + 1.0, + feat.spread_bps, + feat.l1_imbalance, + feat.ofi_sum_5, + (feat.time_since_trade_s + 1.0).ln(), + ]; + let linear = c.iter().zip(x.iter()).map(|(a, b)| a * b).sum::(); + linear.exp().min(50.0) + } + + /// Per-snapshot Bernoulli fill probability for a posted order at `level`. + pub fn fill_prob(&self, side: Side, level: usize, feat: &FillFeatures) -> f32 { + let lam = self.lambda(side, level, feat); + 1.0 - (-lam).exp() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skeleton_has_uniform_fill_prob() { + // β=0 → λ=exp(0)=1 → fill_prob = 1 − exp(−1) ≈ 0.6321 + let m = FillModel::skeleton(); + let feat = FillFeatures { + spread_bps: 0.5, + l1_imbalance: 0.5, + ofi_sum_5: 0.0, + time_since_trade_s: 0.5, + }; + let p = m.fill_prob(Side::Buy, 0, &feat); + assert!( + (p - 0.632).abs() < 0.01, + "skeleton λ=1 → p≈0.632, got {p}" + ); + } + + #[test] + fn fill_prob_bounded_under_outlier_features() { + // Even with huge features and zero coefficients, prob is in [0, 1]. + let m = FillModel::skeleton(); + let feat = FillFeatures { + spread_bps: 1000.0, + l1_imbalance: 1.0, + ofi_sum_5: 100.0, + time_since_trade_s: 1000.0, + }; + let p = m.fill_prob(Side::Buy, 0, &feat); + assert!((0.0..=1.0).contains(&p)); + } + + #[test] + fn lambda_cap_prevents_f32_overflow() { + // Construct a model with a huge β so the linear predictor overflows + // without the cap; the cap should keep λ ≤ 50 and p < 1.0. + let mut m = FillModel::skeleton(); + m.bid_coeffs[0].beta = [100.0; 5]; + let feat = FillFeatures { + spread_bps: 100.0, + l1_imbalance: 1.0, + ofi_sum_5: 10.0, + time_since_trade_s: 100.0, + }; + let lam = m.lambda(Side::Buy, 0, &feat); + assert!(lam.is_finite(), "λ must be finite under cap, got {lam}"); + assert!(lam <= 50.0 + 1e-3, "λ cap violated, got {lam}"); + let p = m.fill_prob(Side::Buy, 0, &feat); + assert!((0.0..=1.0).contains(&p)); + } + + #[test] + fn side_none_returns_zero_rate() { + // Closing actions (Side::None) are handled separately by the env; + // the lambda for None must be 0 so they cannot leak through. + let m = FillModel::skeleton(); + let feat = FillFeatures { + spread_bps: 0.5, + l1_imbalance: 0.5, + ofi_sum_5: 0.0, + time_since_trade_s: 0.5, + }; + assert_eq!(m.lambda(Side::None, 0, &feat), 0.0); + assert_eq!(m.fill_prob(Side::None, 0, &feat), 0.0); + } +} diff --git a/crates/ml/src/env/mod.rs b/crates/ml/src/env/mod.rs index 51f3f8e8c..619876026 100644 --- a/crates/ml/src/env/mod.rs +++ b/crates/ml/src/env/mod.rs @@ -5,10 +5,12 @@ //! placement decisions for a given alpha signal + LOB state. //! //! Module manifest (lands incrementally per the Phase E plan): -//! - `action_space` (Task 3, this commit) — 9 discrete actions -//! - `fill_model` (Task 4) — Poisson regression fill simulator +//! - `action_space` (Task 3) — 9 discrete actions + legality gating +//! - `fill_model` (Task 4, this commit) — Poisson regression fill simulator //! - `execution_env` (Task 6) — 10-dim observation, step/reset loop pub mod action_space; +pub mod fill_model; pub use action_space::{DecodedAction, ExecAction, N_ACTIONS, Placement, Side}; +pub use fill_model::{FillCoeffs, FillFeatures, FillModel};