diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs index 3a3daac54..f99d36f08 100644 --- a/crates/ml-alpha/src/data/loader.rs +++ b/crates/ml-alpha/src/data/loader.rs @@ -507,11 +507,26 @@ impl MultiHorizonLoader { } } +/// Mid price at level 0; returns NaN for one-sided / empty books per +/// the F2 finding (2026-05-22). MBP-10 snapshots at session boundaries, +/// halts, or stale level-0 vacancies have `bid_px = 0` or `ask_px = 0`; +/// the previous blind average produced `mid = real_price / 2 ≈ 2750` +/// (single side) or `mid = 0` (both sides empty). Downstream label +/// generation in `multi_horizon_labels::generate_outcome_labels_ab` +/// only guarded `is_finite()` (not `> 0`), so transitions from mid=0 +/// to mid≈5500 produced `ΔP ≈ 5500`, spuriously satisfying the cost +/// threshold and inflating `pos_fraction` from the expected ~5% to +/// the observed ~40%. NaN return cascades through label generation +/// and the BCE NaN-mask handles it cleanly. fn mid_price_f32(s: &Mbp10Snapshot) -> f32 { let l = s.levels.first().copied().unwrap_or_else(BidAskPair::empty); let bid = BidAskPair::price_to_f64(l.bid_px); let ask = BidAskPair::price_to_f64(l.ask_px); - (0.5 * (bid + ask)) as f32 + if bid > 0.0 && ask > 0.0 { + (0.5 * (bid + ask)) as f32 + } else { + f32::NAN + } } fn convert( diff --git a/crates/ml-alpha/src/multi_horizon_labels.rs b/crates/ml-alpha/src/multi_horizon_labels.rs index 3600208a8..43340af01 100644 --- a/crates/ml-alpha/src/multi_horizon_labels.rs +++ b/crates/ml-alpha/src/multi_horizon_labels.rs @@ -215,7 +215,13 @@ pub fn generate_outcome_labels_ab( for t in 0..n - k { let p_t = prices[t]; let p_kt = prices[t + k]; - if !p_t.is_finite() || !p_kt.is_finite() { + // F2 fix (2026-05-22): also reject p <= 0 to match `generate_labels:75`. + // mid_price_f32 in loader.rs now returns NaN for one-sided books, so + // the is_finite guard alone would suffice — this is belt+suspenders + // for callers that bypass the loader (e.g. direct unit tests passing + // raw price arrays). Without the `> 0` guard a transition from + // mid=0 to mid≈5500 produces ΔP=5500, spuriously triggering y_prof=1. + if !p_t.is_finite() || !p_kt.is_finite() || p_t <= 0.0 || p_kt <= 0.0 { continue; } let delta = p_kt - p_t; diff --git a/crates/ml-alpha/src/trainer/perception.rs b/crates/ml-alpha/src/trainer/perception.rs index be9d580e2..31c283abf 100644 --- a/crates/ml-alpha/src/trainer/perception.rs +++ b/crates/ml-alpha/src/trainer/perception.rs @@ -3641,10 +3641,24 @@ impl PerceptionTrainer { continue; } let true_diff = lt - st; + // F1 fix (2026-05-22): skip flat-true samples + // (`y_prof_long == y_prof_short`). On real ES data + // ~30-50% of samples are flat (neither direction + // profitable). The prior `true_diff == 0 && + // pred_diff == 0.0` match condition required + // float-exact equality on raw logits — unreachable + // for continuous-valued logits, so all flat samples + // counted as misses and depressed dir_acc to ~0.35 + // (matching observed 0.32-0.45). Skipping them + // converts dir_acc into "given a directional + // outcome, did we get the direction right?" with + // proper random baseline 0.5. + if true_diff == 0.0 { + continue; + } let pred_diff = prof_long[idx] - prof_short[idx]; if (true_diff > 0.0 && pred_diff > 0.0) || (true_diff < 0.0 && pred_diff < 0.0) - || (true_diff == 0.0 && pred_diff == 0.0) { per_h_match[h] += 1; }