feat(alpha): Phase E.3 composition backtest — reveals slot 543 needs consumption
Phase E.3 Task 23. Trains the Phase E execution-policy DQN on the first
80% of fxcache snapshots, then evaluates the frozen policy (ε=0) on the
held-out 20% across a transaction-cost sweep. Compares absolute Sharpe
vs the Phase 1d.4 always-market-when-confident baseline.
Pipeline pieces:
- Shared loaders extracted into crates/ml/src/env/loaders.rs (used by
both alpha_dqn_h600_smoke and alpha_compose_backtest)
- alpha_compose_backtest.rs: train DQN on first n_train bars, then
frozen-eval n_eval episodes per cost level
- cost grid: [0.0, 0.0625, 0.125, 0.25, 0.5] (price units per
contract round-turn)
- Annualised Sharpe via per-episode Sharpe × sqrt(episodes/year)
where episodes/year ≈ 252 · 6.5h · 3600s / (horizon · 12s)
Run (horizon=600, 1000 train ep, 500 eval ep/cost, 1.5M snapshots):
cost n_ep mean_R std_R Sharpe/ep Sharpe_ann win_rate
0.0000 500 -11.09 8.03 -1.380 -39.50 0.090
0.0625 500 -20.68 9.88 -2.093 -59.89 0.012
0.1250 500 -29.38 9.49 -3.095 -88.58 0.000
0.2500 500 -48.23 11.90 -4.052 -115.98 0.000
0.5000 500 -84.59 17.43 -4.854 -138.92 0.000
Phase 1d.4 baseline for comparison: +4.4 ann. at cost=0, -4.0 at half-tick.
The Phase E policy LOSES MONEY across the whole cost grid — even at
frictionless cost=0. This is not a contradiction with the H=600 PASS
verdict (rvr=+1.04σ): the smoke's rvr is RELATIVE TO RANDOM, while
backtest Sharpe is ABSOLUTE. "Better than random by 1 std" is still
losing if random loses big.
The diagnostic that the E.2 controller already surfaced:
ISV[543] STACKER_THRESHOLD saturated at upper clamp (0.5) — policy
trades 85% of the time vs the 8% target. Over-trading pays spread on
every bar regardless of alpha confidence. Even with perfect alpha
(Phase 1d.3 AUC=0.673), trading 85% × spread cost > alpha edge.
The Phase 1d.4 baseline beats us at cost=0 because it WAITS unless
|stacker_logit| > threshold — the threshold gate filters bars with
weak alpha signal. The Phase E controller PRODUCES slot 543 but the
DQN's action selection doesn't CONSUME it.
This is exactly what the E.3 backtest is FOR: revealing that the
Phase E.1/E.2 producer-side architecture without consumer-side gating
is incomplete. The composition backtest validates the architecture's
weak link.
NEXT (E.3 task 24-28 or a side fix): wire slot 543 consumption into
the action selection. At each step:
if |ISV[543] − 0.5| > |stacker_logit − 0.5|:
action = Wait // confidence below threshold, sit out
else:
action = argmax(Q)
Or equivalently: action = if confidence_high(alpha_logit, ISV[543])
{ argmax(Q) over Buy/Sell actions } else { Wait }.
Once slot 543 is consumed, re-run alpha_compose_backtest and expect
Sharpe to move toward / past the Phase 1d.4 baseline.
Loader refactor: extracted load_fill_model_from_json, load_alpha_cache,
load_snapshots_from_fxcache from alpha_dqn_h600_smoke.rs into
crates/ml/src/env/loaders.rs. The smoke now calls the shared module
via ml::env::loaders::*. ~150 lines of duplicated code removed.
Build + run verified: smoke still builds clean. Backtest runs in ~30s
(train 8s + eval 20s + setup).
Branch: sp20-aux-h-fixed, pushed.
This commit is contained in:
@@ -184,159 +184,6 @@ struct Cli {
|
||||
out_path: PathBuf,
|
||||
}
|
||||
|
||||
fn load_fill_model(path: &std::path::Path) -> Result<FillModel> {
|
||||
let s = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read fill coeffs JSON at {}", path.display()))?;
|
||||
let v: serde_json::Value = serde_json::from_str(&s)?;
|
||||
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", key);
|
||||
}
|
||||
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))?;
|
||||
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")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the alpha-logit cache produced by `alpha_train_stacker
|
||||
/// --alpha-cache-out`. Binary file: 4 bytes little-endian u32 length
|
||||
/// prefix, then `n` f32 values. Each index aligns to the fxcache bar
|
||||
/// at the same index.
|
||||
fn load_alpha_cache(path: &std::path::Path) -> Result<Vec<f32>> {
|
||||
use std::io::Read;
|
||||
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)
|
||||
}
|
||||
|
||||
/// Build the env::SnapshotRow stream from a precomputed fxcache. Used when
|
||||
/// `--alpha-cache` is set (cache indices align to fxcache bars). Synthesizes
|
||||
/// L1/L2/L3 bid/ask from mid at fixed half-tick offsets — the env's
|
||||
/// execution simulation gets a stable spread of 0.25 (one ES tick) instead
|
||||
/// of real MBP-10 LOB variation. Acceptable for the smoke; production env
|
||||
/// would use real bid/ask.
|
||||
///
|
||||
/// Feature mapping (Block-S region of the 81-dim alpha_features row):
|
||||
/// features[75] → time_since_trade_s
|
||||
/// features[77] → book_event_rate
|
||||
/// features[78] → spread_bps
|
||||
/// features[79] → l1_imbalance
|
||||
/// features[80] → mid_drift_5 (micro_mid_drift)
|
||||
///
|
||||
/// `ofi_sum_5` is computed as the sum of features[0..5] (the multi-level
|
||||
/// OFI L1-L5 block from snapshot_pipeline.rs).
|
||||
fn load_snapshots_from_fxcache(
|
||||
fxcache_path: &std::path::Path,
|
||||
max_snapshots: usize,
|
||||
alpha_cache: Option<&[f32]>,
|
||||
) -> Result<Vec<SnapshotRow>> {
|
||||
use ml_alpha::fxcache_reader::FxCacheReader;
|
||||
|
||||
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 smoke expects ≥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);
|
||||
|
||||
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;
|
||||
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 bid_l1 = mid - 0.125;
|
||||
let ask_l1 = mid + 0.125;
|
||||
let bid_l = [bid_l1, bid_l1 - TICK, bid_l1 - 2.0 * TICK];
|
||||
let ask_l = [ask_l1, ask_l1 + TICK, ask_l1 + 2.0 * TICK];
|
||||
|
||||
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];
|
||||
|
||||
// alpha_logit: real stacker output if cache provided, else 0 (sentinel).
|
||||
let alpha_logit = alpha_cache.map(|c| c[i]).unwrap_or(0.0);
|
||||
// alpha_confidence: distance from 0.5 in probability space.
|
||||
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);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Build the env::SnapshotRow stream from MBP-10 files. Same loader pattern
|
||||
/// as `alpha_random_baseline.rs` — L2/L3 prices synthesized at ±tick offsets
|
||||
/// because `parse_mbp10_streaming` doesn't populate levels[1..10].
|
||||
@@ -542,12 +389,12 @@ fn main() -> Result<()> {
|
||||
.context("controller kernel load")?;
|
||||
|
||||
// --- Load env data ---
|
||||
let fill_model = load_fill_model(&cli.fill_coeffs)?;
|
||||
let fill_model = ml::env::loaders::load_fill_model_from_json(&cli.fill_coeffs)?;
|
||||
info!("Loaded FillModel from {}", cli.fill_coeffs.display());
|
||||
|
||||
// Load alpha cache first (if any) so we can pass it to the fxcache loader.
|
||||
let alpha_cache_vec: Option<Vec<f32>> = if let Some(p) = cli.alpha_cache.as_ref() {
|
||||
let cache = load_alpha_cache(p)?;
|
||||
let cache = ml::env::loaders::load_alpha_cache(p)?;
|
||||
info!("Loaded alpha cache: {} entries from {}", cache.len(), p.display());
|
||||
Some(cache)
|
||||
} else {
|
||||
@@ -560,7 +407,7 @@ fn main() -> Result<()> {
|
||||
let rows: Vec<SnapshotRow> = match (cli.fxcache_path.as_ref(), cli.mbp10_dir.as_ref()) {
|
||||
(Some(fxc), _) => {
|
||||
info!("Loading snapshots from fxcache: {}", fxc.display());
|
||||
load_snapshots_from_fxcache(
|
||||
ml::env::loaders::load_snapshots_from_fxcache(
|
||||
fxc,
|
||||
cli.max_snapshots,
|
||||
alpha_cache_vec.as_deref(),
|
||||
|
||||
Reference in New Issue
Block a user