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:
159
crates/ml/src/env/fill_model.rs
vendored
Normal file
159
crates/ml/src/env/fill_model.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
6
crates/ml/src/env/mod.rs
vendored
6
crates/ml/src/env/mod.rs
vendored
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user