Files
foxhunt/crates/ml/src/env/loaders.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

259 lines
10 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 loader helpers shared by `alpha_dqn_h600_smoke` and
//! `alpha_compose_backtest` (and any future Phase E binary).
//!
//! Three loaders:
//!
//! - [`load_alpha_cache`] — binary cache produced by
//! `alpha_train_stacker --alpha-cache-out`. Format: `[u32 n] [f32; n]`
//! little-endian. Each f32 is the per-bar stacker logit; bars
//! before `seq_len - 1` are 0.0 (Pearl A sentinel).
//!
//! - [`load_fill_model_from_json`] — parses the FillCoeffs JSON
//! emitted by `alpha_fit_fill_model`. Expects `bid_coeffs` and
//! `ask_coeffs` as 3-row arrays of 5-element f32 vectors.
//!
//! - [`load_snapshots_from_fxcache`] — builds the env's
//! `Vec<SnapshotRow>` from a precomputed snapshot fxcache. Mid
//! comes from `raw_close`; bid/ask synthesized at fixed half-tick
//! offsets (the parser bug for L2-L9 is fixed but the fxcache
//! itself doesn't store the LOB so we synthesize). 81-dim Block-S
//! features feed `spread_bps`, `l1_imbalance`, `ofi_sum_5`,
//! `time_since_trade_s`, `book_event_rate`, `mid_drift_5`.
//! `alpha_logit` is filled from the optional alpha cache (else 0.0).
use std::io::Read;
use std::path::Path;
use anyhow::{Context, Result};
use ml_alpha::fxcache_reader::{COL_RAW_CLOSE, FEAT_DIM, FxCacheReader};
use serde_json::Value;
use tracing::{info, warn};
use crate::env::execution_env::SnapshotRow;
use crate::env::fill_model::{FillCoeffs, FillModel};
/// ES futures minimum price increment.
const TICK: f32 = 0.25;
/// Load the alpha-logit cache (`[u32 n] [f32; n]` little-endian binary).
/// Each index aligns to the fxcache bar at the same index.
pub fn load_alpha_cache(path: &Path) -> Result<Vec<f32>> {
let mut f = std::fs::File::open(path)
.with_context(|| format!("open alpha cache {}", path.display()))?;
let mut len_bytes = [0u8; 4];
f.read_exact(&mut len_bytes).context("read alpha-cache header")?;
let n = u32::from_le_bytes(len_bytes) as usize;
let mut buf = vec![0u8; n * 4];
f.read_exact(&mut buf).context("read alpha-cache body")?;
let mut out = Vec::with_capacity(n);
for i in 0..n {
let off = i * 4;
let v = f32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]);
out.push(v);
}
Ok(out)
}
/// Parse FillModel from JSON (the artifact produced by
/// `alpha_fit_fill_model`). Expects `bid_coeffs` and `ask_coeffs` keys
/// each holding a 3-row × 5-element f32 array.
pub fn load_fill_model_from_json(path: &Path) -> Result<FillModel> {
let s = std::fs::read_to_string(path)
.with_context(|| format!("read fill coeffs JSON at {}", path.display()))?;
let v: Value = serde_json::from_str(&s)
.with_context(|| format!("parse 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 `{}`", 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")?,
})
}
/// Build `Vec<SnapshotRow>` from a precomputed fxcache. Mid from
/// `raw_close`; bid/ask synthesized at fixed half-tick offsets; 81-dim
/// Block-S features feed the runtime feature fields. If `alpha_cache`
/// is `Some`, each `SnapshotRow.alpha_logit` is populated from the
/// cache (and `alpha_confidence = |sigmoid(z) 0.5|`); otherwise 0.0.
/// Maximum spread we'll accept from `spread_bps` (in price units) when
/// `use_real_spread` is true. Above this we cap to the max — protects
/// against fxcache feature outliers / NaN / unrealistic wide ticks.
/// 10 ticks = 2.50 in ES futures price = ~5.6 bps at mid=4500.
const MAX_REAL_SPREAD_PRICE: f32 = 10.0 * TICK;
/// Build `Vec<SnapshotRow>` from a precomputed fxcache. Mid from
/// `raw_close`; 81-dim Block-S features feed the runtime feature fields.
///
/// **Bid/ask synthesis:**
/// - `use_real_spread = false` (legacy): bid_l1 = mid 0.125, ask_l1
/// = mid + 0.125. Fixed half-tick spread, equivalent to the original
/// loader behaviour and matched the Phase E.1/2/3 smoke/backtest runs.
/// - `use_real_spread = true` (Path 3): spread_price derived from
/// fxcache `features[78]` (`spread_bps`); bid_l1 = mid spread/2,
/// ask_l1 = mid + spread/2. Variable per-bar spread reflecting
/// actual market state. Floored at 1 tick (=0.25), capped at 10 ticks
/// to protect against feature outliers.
///
/// L2/L3 are always synthesized at ±TICK offsets from L1 — the fxcache
/// doesn't store depth beyond L1 spread+imbalance, and L2/L3 fills are
/// rare (most policy decisions hinge on L1 + market crosses).
///
/// If `alpha_cache` is `Some`, each row's `alpha_logit` is populated
/// from the cache (and `alpha_confidence = |sigmoid(z) 0.5|`).
pub fn load_snapshots_from_fxcache(
fxcache_path: &Path,
max_snapshots: usize,
alpha_cache: Option<&[f32]>,
use_real_spread: bool,
) -> Result<Vec<SnapshotRow>> {
let reader = FxCacheReader::open(fxcache_path)
.with_context(|| format!("open fxcache {}", fxcache_path.display()))?;
let alpha_dim = reader
.alpha_feature_dim()
.ok_or_else(|| anyhow::anyhow!("fxcache lacks alpha column"))?;
if alpha_dim < 81 {
anyhow::bail!(
"fxcache alpha_dim={} but expected ≥81 (snapshot_pipeline layout)",
alpha_dim
);
}
let n_bars_total = reader.bar_count();
let n = n_bars_total.min(max_snapshots);
info!("fxcache: {} total bars, taking {} for the env", n_bars_total, n);
info!(
"fxcache loader: spread mode = {}",
if use_real_spread { "REAL (derived from features[78] spread_bps)" }
else { "FIXED ±0.125-tick" }
);
if let Some(cache) = alpha_cache {
if cache.len() < n {
anyhow::bail!(
"alpha cache has {} entries but env wants {} bars",
cache.len(),
n
);
}
}
let mut rows: Vec<SnapshotRow> = Vec::with_capacity(n);
let mut n_degenerate = 0_usize;
// Diagnostic: spread distribution (real mode only).
let mut sum_spread = 0.0_f64;
let mut min_spread = f32::INFINITY;
let mut max_spread = f32::NEG_INFINITY;
let mut n_floor_hits = 0_usize;
let mut n_cap_hits = 0_usize;
for i in 0..n {
let rec = reader.record(i);
let mid = rec.targets[COL_RAW_CLOSE - FEAT_DIM];
if !mid.is_finite() || mid <= 0.0 {
n_degenerate += 1;
continue;
}
let features = reader
.alpha_features(i)
.ok_or_else(|| anyhow::anyhow!("missing alpha row at bar {}", i))?;
let spread_bps = features[78];
let l1_imbalance = features[79];
let ofi_sum_5 = features[0..5].iter().sum::<f32>();
let mid_drift_5 = features[80];
let time_since_trade_s = features[75];
let book_event_rate = features[77];
let (bid_l1, ask_l1) = if use_real_spread {
// spread_bps = 10000 × (ask bid) / mid → spread_price = bps × mid / 10000
let raw = if spread_bps.is_finite() && spread_bps > 0.0 {
spread_bps / 10_000.0 * mid
} else {
TICK // sentinel: fall back to 1-tick spread if bps is non-finite
};
let mut clamped = raw;
if clamped < TICK {
clamped = TICK;
n_floor_hits += 1;
}
if clamped > MAX_REAL_SPREAD_PRICE {
clamped = MAX_REAL_SPREAD_PRICE;
n_cap_hits += 1;
}
sum_spread += clamped as f64;
if clamped < min_spread { min_spread = clamped; }
if clamped > max_spread { max_spread = clamped; }
let half = clamped * 0.5;
(mid - half, mid + half)
} else {
(mid - 0.125, mid + 0.125)
};
// Phase E.4.A.3: extend to L1-L10 depth. L2-L10 synthesized at
// ±TICK offsets from L1; real L4-L10 from MBP-10 lands in
// Task 5 follow-on (the loader doesn't have MBP-10 access yet).
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;
}
let alpha_logit = alpha_cache.map(|c| c[i]).unwrap_or(0.0);
let alpha_confidence = {
let p = 1.0_f32 / (1.0 + (-alpha_logit.clamp(-50.0, 50.0)).exp());
(p - 0.5).abs()
};
rows.push(SnapshotRow {
mid_price: mid,
bid_l,
ask_l,
alpha_logit,
alpha_confidence,
spread_bps,
l1_imbalance,
ofi_sum_5,
mid_drift_5,
time_since_trade_s,
book_event_rate,
});
}
if n_degenerate > 0 {
warn!(
"fxcache loader: skipped {} degenerate bars (non-finite or zero mid)",
n_degenerate
);
}
if use_real_spread && !rows.is_empty() {
let mean_spread = (sum_spread / rows.len() as f64) as f32;
info!(
"fxcache loader: real-spread stats — mean={:.4} ({:.2} ticks), min={:.4}, max={:.4}, floor_hits={}/{}, cap_hits={}/{}",
mean_spread, mean_spread / TICK,
min_spread, max_spread,
n_floor_hits, rows.len(),
n_cap_hits, rows.len(),
);
}
Ok(rows)
}