Files
foxhunt/crates/ml/examples/alpha_random_baseline.rs
jgrusewski eb49e2a0f7 feat(alpha): Phase E.3 follow-up — C51 distributional Q + Thompson + L1-L10 depth + falsifications
C51 distributional Q-network with GPU Thompson selection borrowed
minimally from production (alpha_c51.cu: forward, project, grad,
expected_q, thompson_select kernels; ~260 lines). Uses Huber
negative-tail compression in projection per production
block_bellman_project_f. Action selection 100% GPU via mapped-pinned
i32 output + __threadfence_system + host volatile read (matches
gpu_training_guard MappedBuffer pattern).

Backtest result (2D sweep, 500 episodes per cell, 30 cells):
  cost=0    C51 +10.41 vs linear-Q -15.72  (+26pt, BEATS Phase 1d.4
                                            no-RL baseline +4.4 by 6pt)
  cost=0.125 C51 -13.81 vs -29.17  (+15pt closes half-tick gap)
Win rate at cost=0 best τ: linear-Q 0.008 → C51 0.552.

Calibration hypothesis vindicated; documented in
memory/pearl_c51_thompson_closed_phase_e3_gap.md.

Also in this commit (Phase E.3 follow-up cleanup):
- --pruned-actions falsified (2.4× worse Sharpe). Documented in
  memory/pearl_action_pruning_falsified.md.
- --real-spread falsified for ES futures (76% of bars at 1-tick floor).
- SnapshotRow bid_l/ask_l extended from [f32; 3] to [f32; 10].
  L4-L10 synthesized in this commit; real MBP-10 peek lands in E.4.A T5.
- docs/isv-slots.md updated per kernel-audit-doc hook requirement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:43:57 +02:00

356 lines
13 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 7 — random-uniform policy reward baseline.
//!
//! Loads MBP-10 snapshots into the Phase E `ExecutionEnv` with a `FillModel`
//! deserialized from JSON (the artifact produced by Task 5b), runs N random-
//! uniform episodes from randomly-sampled starting cursors, and reports
//! mean / std / percentile distribution of terminal reward. This baseline
//! defines the kill-criterion threshold the E.1 DQN smoke must exceed
//! (`mean + 2σ` — written to ISV slots 547/548 in the trainer).
//!
//! ## Run
//!
//! ```bash
//! SQLX_OFFLINE=true cargo run -p ml --release --example alpha_random_baseline -- \
//! --mbp10-dir /home/jgrusewski/Work/foxhunt/test_data/futures-baseline-mbp10/ES.FUT \
//! --fill-coeffs config/ml/alpha_fill_coeffs.json \
//! --horizon 600 \
//! --n-episodes 10000 \
//! --out-path config/ml/alpha_random_baseline.json
//! ```
//!
//! ## L1-only data limitation (inherited)
//!
//! Same as the fit binary: `parse_mbp10_streaming` populates only L1. L2/L3
//! posting prices in the synthesized `SnapshotRow` are L1 ± 0.25-tick
//! offsets (ES futures tick size). Fill probabilities at L2/L3 still derive
//! from the JSON-supplied coefficients (which themselves are L1 with
//! attenuated β_0).
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,
};
use ml::env::action_space::N_ACTIONS;
use ml::env::execution_env::{
EpisodeState, ExecutionEnv, ExecutionEnvConfig, ReplayRng, SnapshotRow,
};
use ml::env::fill_model::{FillCoeffs, FillModel};
use ml::trainers::dqn::collect_dbn_files_recursive;
/// Convert raw `Mbp10Msg.price` (i64 fixed-point at 1e9 scale per the
/// databento DBN format) to f32 in price units. See
/// `alpha_fit_fill_model.rs` for the rationale (mismatch with
/// `BidAskPair::price_to_f64`'s 1e12 convention).
#[inline]
fn raw_price_to_f32(fixed: i64) -> f32 {
(fixed as f64 * 1e-9) as f32
}
/// ES futures minimum price increment.
const TICK: f32 = 0.25;
#[derive(Debug, Parser)]
#[command(
name = "alpha_random_baseline",
about = "Phase E.0 Task 7 — random-uniform policy reward baseline"
)]
struct Cli {
/// Directory containing MBP-10 `.dbn[.zst]` files.
#[arg(long)]
mbp10_dir: PathBuf,
/// Path to fitted FillModel JSON (from Task 5b).
#[arg(long, default_value = "config/ml/alpha_fill_coeffs.json")]
fill_coeffs: PathBuf,
/// Episode horizon in snapshots.
#[arg(long, default_value_t = 600)]
horizon: usize,
/// Number of random episodes to simulate.
#[arg(long, default_value_t = 10_000)]
n_episodes: usize,
/// Fixed trade size per fill.
#[arg(long, default_value_t = 1)]
trade_size: i32,
/// Per-contract round-turn cost in price units.
#[arg(long, default_value_t = 0.0625)]
cost_per_contract: f32,
/// Master RNG seed.
#[arg(long, default_value_t = 0xCAFEBABE_u64)]
seed: u64,
/// MBP-10 events between snapshot emissions.
#[arg(long, default_value_t = 50)]
snapshot_interval: usize,
/// Cap on snapshots loaded (memory budget).
#[arg(long, default_value_t = 500_000)]
max_snapshots: usize,
/// Output JSON path.
#[arg(long, default_value = "config/ml/alpha_random_baseline.json")]
out_path: PathBuf,
}
fn load_fill_model(path: &std::path::Path) -> Result<FillModel> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("reading fill coeffs JSON at {}", path.display()))?;
let v: serde_json::Value = serde_json::from_str(&s)
.with_context(|| format!("parsing fill coeffs JSON at {}", path.display()))?;
let parse_levels = |key: &str| -> Result<[FillCoeffs; 3]> {
let arr = v[key]
.as_array()
.ok_or_else(|| anyhow::anyhow!("missing/non-array field `{}`", key))?;
if arr.len() != 3 {
anyhow::bail!("`{}` must have 3 entries, got {}", key, arr.len());
}
let mut out: [FillCoeffs; 3] = [FillCoeffs { beta: [0.0; 5] }; 3];
for (i, lvl) in arr.iter().enumerate() {
let vv = lvl
.as_array()
.ok_or_else(|| anyhow::anyhow!("`{}`[{}] not array", key, i))?;
if vv.len() != 5 {
anyhow::bail!("`{}`[{}] must have 5 floats, got {}", key, i, vv.len());
}
for k in 0..5 {
out[i].beta[k] = vv[k]
.as_f64()
.ok_or_else(|| anyhow::anyhow!("`{}`[{}][{}] not number", key, i, k))?
as f32;
}
}
Ok(out)
};
Ok(FillModel {
bid_coeffs: parse_levels("bid_coeffs")?,
ask_coeffs: parse_levels("ask_coeffs")?,
})
}
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 7 — random-uniform baseline starting");
info!(" mbp10_dir = {}", cli.mbp10_dir.display());
info!(" fill_coeffs = {}", cli.fill_coeffs.display());
info!(" horizon = {}", cli.horizon);
info!(" n_episodes = {}", cli.n_episodes);
info!(" trade_size = {}", cli.trade_size);
info!(" cost = {:.4}", cli.cost_per_contract);
info!(" seed = {:#x}", cli.seed);
// --- 1. Load FillModel ---
let fill_model = load_fill_model(&cli.fill_coeffs)?;
info!(
"Loaded FillModel from {} (bid β_0={:+.3}, ask β_0={:+.3})",
cli.fill_coeffs.display(),
fill_model.bid_coeffs[0].beta[0],
fill_model.ask_coeffs[0].beta[0],
);
// --- 2. Stream MBP-10 → env::SnapshotRow ---
let parser = DbnParser::new().context("DbnParser::new")?;
let files = collect_dbn_files_recursive(&cli.mbp10_dir);
if files.is_empty() {
anyhow::bail!("no MBP-10 .dbn[.zst] files in {:?}", cli.mbp10_dir);
}
info!("Found {} MBP-10 file(s)", files.len());
let mut rows: Vec<SnapshotRow> = Vec::with_capacity(cli.max_snapshots);
'files: for file in &files {
let mut hit_limit = false;
let result =
parser.parse_mbp10_streaming(file, cli.snapshot_interval, |snap: &Mbp10Snapshot| {
if rows.len() >= cli.max_snapshots {
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 {
return;
}
let mid = 0.5 * (bid_l1 + ask_l1);
let spread_bps = 10_000.0 * (ask_l1 - bid_l1) / 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
};
let ofi_sum_5 = bid_sz - ask_sz;
// Phase E.4.A.3: L1-L10 depth — L1 from real MBP-10,
// L2-L10 synthesized at ±tick offsets (parser fix
// 5c0bcb1fd makes real L2-L10 available; wire in
// Task 5 follow-on).
let mut bid_l = [0.0_f32; 10];
let mut ask_l = [0.0_f32; 10];
for k in 0..10 {
bid_l[k] = bid_l1 - (k as f32) * TICK;
ask_l[k] = ask_l1 + (k as f32) * TICK;
}
rows.push(SnapshotRow {
mid_price: mid,
bid_l,
ask_l,
alpha_logit: 0.0, // Random policy ignores state — placeholder.
alpha_confidence: 0.5, // placeholder.
spread_bps,
l1_imbalance,
ofi_sum_5,
mid_drift_5: 0.0, // placeholder (no rolling history maintained).
time_since_trade_s: 0.0, // no trade tape loaded in baseline.
book_event_rate: 5.0, // plausible default, doesn't influence reward.
});
});
match result {
Ok(c) => info!(
" {} → {} streamed snapshots",
file.file_name().unwrap_or_default().to_string_lossy(),
c
),
Err(e) => warn!("parser error on {}: {}", file.display(), e),
}
if hit_limit {
info!("max_snapshots cap reached; stopping iteration");
break 'files;
}
}
info!("Loaded {} snapshots into env", rows.len());
if rows.len() <= cli.horizon {
anyhow::bail!(
"insufficient snapshots ({}) for horizon ({}); reduce horizon or raise --max-snapshots",
rows.len(),
cli.horizon
);
}
// --- 3. Construct env, run random episodes ---
let n_rows = rows.len();
let mut env = ExecutionEnv::new(
ExecutionEnvConfig {
horizon_snapshots: cli.horizon,
trade_size_contracts: cli.trade_size,
cost_per_contract: cli.cost_per_contract,
},
fill_model,
rows,
cli.seed,
);
let mut episode_rng = ReplayRng::new(cli.seed.wrapping_add(0xDEAD_BEEF));
let mut rewards: Vec<f32> = Vec::with_capacity(cli.n_episodes);
let max_start = n_rows.saturating_sub(cli.horizon + 1).max(1);
let mut n_fills_total: u64 = 0;
for ep in 0..cli.n_episodes {
let start_cursor = (episode_rng.next_u64() as usize) % max_start;
let env_seed = episode_rng.next_u64();
let mut action_rng = ReplayRng::new(episode_rng.next_u64());
env.reset_at(env_seed, start_cursor);
let mut state = EpisodeState::new();
let mut terminal_reward = 0.0_f32;
loop {
let action = (action_rng.next_u64() % N_ACTIONS as u64) as u8;
match env.step(action, &mut state) {
Some((_, reward, done)) => {
if done {
terminal_reward = reward;
break;
}
}
None => break,
}
}
rewards.push(terminal_reward);
n_fills_total += state.n_fills as u64;
if (ep + 1) % 1000 == 0 {
info!(
" episode {}/{} (latest_reward={:+.4}, fills={})",
ep + 1,
cli.n_episodes,
terminal_reward,
state.n_fills
);
}
}
// --- 4. Statistics ---
let n = rewards.len() as f64;
let mean: f64 = rewards.iter().map(|r| *r as f64).sum::<f64>() / n;
let var: f64 = rewards
.iter()
.map(|r| (*r as f64 - mean).powi(2))
.sum::<f64>()
/ n;
let std = var.sqrt();
let mut sorted = rewards.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pick = |q: f64| -> f32 {
let idx = (q * (n - 1.0)).round() as usize;
sorted[idx.min(sorted.len() - 1)]
};
let p05 = pick(0.05);
let p25 = pick(0.25);
let p50 = pick(0.50);
let p75 = pick(0.75);
let p95 = pick(0.95);
let kill_threshold = mean + 2.0 * std;
let avg_fills_per_ep = n_fills_total as f64 / n;
info!(
"=== Random-uniform baseline ({} episodes, horizon {}) ===",
cli.n_episodes, cli.horizon
);
info!(" mean = {:+.6}", mean);
info!(" std = {:.6}", std);
info!(" p05 = {:+.6}", p05);
info!(" p25 = {:+.6}", p25);
info!(" p50 (median) = {:+.6}", p50);
info!(" p75 = {:+.6}", p75);
info!(" p95 = {:+.6}", p95);
info!(" kill (mean + 2σ) = {:+.6}", kill_threshold);
info!(" avg fills/ep = {:.2}", avg_fills_per_ep);
// --- 5. Save JSON ---
let json = serde_json::json!({
"phase": "E.0 Task 7",
"n_episodes": cli.n_episodes,
"horizon": cli.horizon,
"trade_size_contracts": cli.trade_size,
"cost_per_contract": cli.cost_per_contract,
"seed": cli.seed,
"n_snapshots_loaded": n_rows,
"snapshot_interval": cli.snapshot_interval,
"fill_coeffs_path": cli.fill_coeffs.to_string_lossy(),
"mean_reward": mean,
"std_reward": std,
"p05_reward": p05,
"p25_reward": p25,
"p50_reward": p50,
"p75_reward": p75,
"p95_reward": p95,
"kill_threshold_mean_plus_2sigma": kill_threshold,
"avg_fills_per_episode": avg_fills_per_ep,
});
let mut f = File::create(&cli.out_path).context("create out file")?;
write!(f, "{}", serde_json::to_string_pretty(&json)?)?;
info!("Wrote results to {}", cli.out_path.display());
Ok(())
}