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>
356 lines
13 KiB
Rust
356 lines
13 KiB
Rust
//! 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(())
|
||
}
|