feat(alpha): Poisson regression fill model scaffold (coeffs fitted in Task 5)

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.
This commit is contained in:
jgrusewski
2026-05-15 12:27:57 +02:00
parent 94f8f65571
commit a5de50503f
2 changed files with 163 additions and 2 deletions

159
crates/ml/src/env/fill_model.rs vendored Normal file
View File

@@ -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::<f32>();
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);
}
}

View File

@@ -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};