//! Alpha feature pipeline — orchestrates all authored Block A-W feature
//! extractors into per-bar feature vectors of length `ALPHA_FEATURE_DIM` (134).
//!
//! ## Layout
//!
//! Each output row is a `Vec<f32>` of length 134, with this fixed ordering:
//!
//! | Range | Block | Description | Source |
//! |---|---|---|---|
//! | 0..15  | A   | Price volatility estimators (Parkinson, GK, YZ, Hurst, fractal, ...) | `PriceFeatureExtractor::extract_all(bars)` |
//! | 15..50 | B-E | (RESERVED — zero-filled in Stage 2; future: Volume + Stat + ADX + Time blocks) | placeholder |
//! | 50..55 | F   | Multi-level OFI L1-L5 | `OFICalculator::calc_ofi_per_level(curr, prev)` |
//! | 55..60 | G   | log-GOFI L1-L5 | `OFICalculator::apply_log_gofi(per_level)` |
//! | 60..70 | H   | Per-level book deltas L1-L5 (5 bid + 5 ask) | `OFICalculator::calc_book_deltas_per_level(curr, prev)` |
//! | 70..75 | T   | Multi-level Kyle's λ L1-L5 | `MultiLevelKyleLambda::maybe_update(ts, ret, ofi_per_level)` |
//! | 75..78 | M   | Frac-diff close at d ∈ {0.3, 0.5, 0.7} | `FracDiffF64::process(close)` × 3 |
//! | 78..82 | AA  | Realized variance decomp (RV, RV-, RV+, jump) | `ReturnMoments::realized_variance_decomposition()` |
//! | 82..86 | EE  | Skew/kurt at short/long windows | `ReturnMoments::realized_moments()` |
//! | 86..91 | U   | VPIN bucket trajectory (5 buckets) | `OFICalculator::vpin_bucket_trajectory()` |
//! | 91..95 | CC  | Book slope + convexity (bid_slope, bid_convex, ask_slope, ask_convex) | `OFICalculator::calc_slope_and_convexity(snap)` |
//! | 95..103| N+V | Microprice features (Stoikov + Δ + Δ² + residual + sign + multi-level + Cartea) | `MicropriceFeatures::update(snap)` |
//! | 103..107| O  | Hasbrouck effective + realized spread (4 dims) | `SpreadDecomposition::features()` |
//! | 107..112| X  | trailing trend scan (5 dims) | `TrendScanner::scan(trailing_prices)` |
//! | 112..115| Y  | LOB PCA top-3 scores | `OnlineLobPca::update_and_project(snap)` |
//! | 115..117| BB | Bouchaud trade-sign autocorr + branching ratio proxy | `BouchaudFeatures::features()` |
//! | 117..121| R  | Hawkes MLE (μ, α, β, n) | `HawkesEstimator::features()` |
//! | 121..124| Z  | PIN EM-MLE (PIN, μ_informed, ε_uninformed) | `PinEstimator::features()` |
//! | 124..130| DD | Deseasonalized + 4-stage event encoding | `SeasonalityAndEventFeatures::update(...)` |
//! | 130..134| W  | Order arrival/cancel/modify rates + TQR | `OrderEventCounters::features()` |
//!
//! ## Statefulness contract
//!
//! Each per-bar emission depends on the cumulative state of all aggregators
//! up to that bar. The pipeline:
//! - Instantiates each aggregator once at the start of the pipeline.
//! - Feeds trade events to all event-based aggregators (Bouchaud, Hawkes,
//!   OrderEventCounters, SpreadDecomposition) **in timestamp order, before**
//!   the bar they precede is emitted.
//! - Calls `update*` methods on bar-level aggregators (ReturnMoments,
//!   MicropriceFeatures, MultiLevelKyleLambda, etc.) **once per bar**, then
//!   reads their current features.

use std::collections::VecDeque;

use data::providers::databento::mbp10::Mbp10Snapshot;
use rayon::prelude::*;

use crate::OHLCVBar;
use crate::Mbp10Trade;

// Block A-E factory extractors
use crate::adx_features::AdxFeatureExtractor;
use crate::price_features::PriceFeatureExtractor;
use crate::regime_adx::RegimeADXFeatures;
use crate::statistical_features::StatisticalFeatureExtractor;
use crate::time_features::TimeFeatureExtractor;
use crate::volume_features::VolumeFeatureExtractor;

// Block F-W author modules
use crate::bouchaud_features::BouchaudFeatures;
use crate::frac_diff_adapter::FracDiffF64;
use crate::hawkes_mle::HawkesEstimator;
use crate::lob_pca::OnlineLobPca;
use crate::microprice::MicropriceFeatures;
use crate::microstructure_features::MultiLevelKyleLambda;
use crate::ofi_calculator::OFICalculator;
use crate::order_events::OrderEventCounters;
use crate::pin_estimator::PinEstimator;
use crate::return_moments::ReturnMoments;
use crate::seasonality_events::SeasonalityAndEventFeatures;
use crate::spread_decomposition::SpreadDecomposition;
use crate::trend_scanning::TrendScanner;

/// Bar history window used by stateless extractors (Price, Statistical).
/// 100 bars ≈ 13 minutes at 8 sec/bar — covers all rolling windows in those
/// factory modules without being wasteful.
const BAR_WINDOW: usize = 100;

/// Total alpha feature dimensionality. Mirrors `ml::fxcache::ALPHA_FEATURE_DIM`.
pub const ALPHA_FEATURE_DIM: usize = 134;

/// Max trailing horizon for the trend scanner. Zero forward reads.
const TREND_SCAN_MAX_HORIZON: usize = 40;

/// Bars of pre-roll per parallel chunk. Each chunk runs WARMUP_BARS leading
/// bars to warm its fresh aggregators (Hawkes excitation history, Bouchaud
/// trade-imbalance EMA, frac-diff FIR, LOB PCA covariance, microprice EMA,
/// spread_decomp running stats) before emitting feature rows. 2000 bars at
/// ~3-10 trades/bar gives 6k-20k trade events — sufficient to refill Hawkes's
/// 500-event history twice over and saturate the longer-memory EMAs. The
/// warmup overhead per chunk is bounded by `WARMUP_BARS / chunk_size` which
/// is well under 1% at production scale.
const WARMUP_BARS: usize = 2_000;

/// Extract alpha features for `n_output` bars starting at `bars[bar_start_offset]`.
///
/// `bar_start_offset` is the WARMUP value used by the production pipeline
/// (so the first emitted bar already has enough history for windowed
/// extractors). `n_output` = `bars.len() - bar_start_offset - look_ahead`
/// per the production target-builder contract.
///
/// Returns a `Vec<Vec<f32>>` of length `n_output`, each inner `Vec` having
/// length `ALPHA_FEATURE_DIM`.
///
/// # Parallelism
///
/// The output range is split into `rayon::current_num_threads()` contiguous
/// chunks (one per CPU). Each chunk runs `process_chunk` with its own fresh
/// aggregator state, pre-rolling [`WARMUP_BARS`] leading bars without emission
/// so that the first emitted bar has well-saturated EMAs/FIRs/excitations.
/// Chunk 0 starts at `out_idx = 0` with cold state — identical to the original
/// sequential behaviour. Other chunks differ from a fully-serial run by
/// floating-point ε once warmup completes (state initialisers + EMA memory
/// loss are bounded by the pre-roll length).
///
/// Trade-feed semantics are preserved: each trade is fed to a single chunk's
/// aggregators exactly once, partitioned by bar timestamp. `trade_cursor` is
/// seeded per chunk via `partition_point` on the previous warmup bar's
/// timestamp.
pub fn extract_alpha_features(
    bars: &[OHLCVBar],
    snapshots: &[Mbp10Snapshot],
    trades: &[Mbp10Trade],
    bar_start_offset: usize,
    n_output: usize,
) -> Vec<Vec<f32>> {
    if n_output == 0 {
        return Vec::new();
    }

    // ── Pre-index snapshots by timestamp (shared, read-only across chunks) ──
    // Each bar gets the snapshot whose timestamp is the latest ≤ bar.timestamp.
    let bar_snap_idx: Vec<usize> = bars
        .iter()
        .map(|bar| {
            let bar_ts_ns = bar.timestamp.timestamp_nanos_opt().unwrap_or(0);
            let idx = snapshots
                .partition_point(|s| (s.timestamp as i64) <= bar_ts_ns);
            idx.saturating_sub(1)
        })
        .collect();

    // ── Chunk the emit range ──
    let n_chunks = rayon::current_num_threads().max(1).min(n_output);
    let chunk_size = n_output.div_ceil(n_chunks);
    let chunks: Vec<(usize, usize, usize)> = (0..n_chunks)
        .map(|k| {
            let emit_start = k * chunk_size;
            let emit_end = ((k + 1) * chunk_size).min(n_output);
            (k, emit_start, emit_end)
        })
        .filter(|(_, s, e)| s < e)
        .collect();

    tracing::info!(
        "alpha pipeline: dispatching {} chunks (rayon threads={}, chunk_size={}, warmup_bars={})",
        chunks.len(),
        rayon::current_num_threads(),
        chunk_size,
        WARMUP_BARS
    );
    let t_pipeline_start = std::time::Instant::now();

    // Parallel per-chunk computation; flatten into the final contiguous Vec.
    let features: Vec<Vec<f32>> = chunks
        .par_iter()
        .flat_map_iter(|&(chunk_idx, emit_start, emit_end)| {
            process_chunk(
                chunk_idx,
                bars,
                snapshots,
                trades,
                &bar_snap_idx,
                bar_start_offset,
                emit_start,
                emit_end,
            )
            .into_iter()
        })
        .collect();

    tracing::info!(
        "alpha pipeline: all chunks complete, {} rows emitted in {:.1}s",
        features.len(),
        t_pipeline_start.elapsed().as_secs_f64()
    );
    features
}

/// Process one chunk [emit_start..emit_end) with fresh aggregator state,
/// pre-rolling [`WARMUP_BARS`] leading bars to saturate stateful blocks.
fn process_chunk(
    chunk_idx: usize,
    bars: &[OHLCVBar],
    snapshots: &[Mbp10Snapshot],
    trades: &[Mbp10Trade],
    bar_snap_idx: &[usize],
    bar_start_offset: usize,
    emit_start: usize,
    emit_end: usize,
) -> Vec<Vec<f32>> {
    let warmup_start = emit_start.saturating_sub(WARMUP_BARS);
    let t_chunk_start = std::time::Instant::now();
    tracing::info!(
        "alpha chunk[{}] start: emit=[{}..{}) warmup_start={}",
        chunk_idx,
        emit_start,
        emit_end,
        warmup_start
    );

    // ── Stateful aggregators (persistent across bars) ──
    // Block A-E factory aggregators
    let mut bar_window: VecDeque<OHLCVBar> = VecDeque::with_capacity(BAR_WINDOW);
    let mut volume_extractor = VolumeFeatureExtractor::new();
    let mut adx_extractor = AdxFeatureExtractor::new();
    let mut regime_adx = RegimeADXFeatures::new(14);
    let mut time_extractor = TimeFeatureExtractor::new();

    // Block F-W author aggregators
    let mut kyle = MultiLevelKyleLambda::with_defaults();
    let mut fd_03 = FracDiffF64::with_defaults(0.3);
    let mut fd_05 = FracDiffF64::with_defaults(0.5);
    let mut fd_07 = FracDiffF64::with_defaults(0.7);
    let mut return_moments = ReturnMoments::new();
    let mut microprice = MicropriceFeatures::new();
    let mut spread_decomp = SpreadDecomposition::with_defaults();
    let mut lob_pca = OnlineLobPca::with_defaults();
    let mut bouchaud = BouchaudFeatures::with_defaults();
    let mut hawkes = HawkesEstimator::with_defaults();
    let mut pin = PinEstimator::with_defaults();
    let mut seasonality_events = SeasonalityAndEventFeatures::new();
    let mut order_events = OrderEventCounters::with_defaults();
    let trend_scanner = TrendScanner::with_defaults();

    // OFICalculator instance for stateful VPIN bucket tracking.
    let mut ofi_calc = OFICalculator::new();

    // ── Seed trade_cursor at the first trade belonging to this chunk ──
    // For chunk 0 (warmup_start == 0) the cursor starts at 0 so the trade-feed
    // inner loop replays *all* pre-window trades into the aggregators on the
    // first iteration — preserving the original sequential semantics.
    //
    // For other chunks we partition past the previous warmup bar's timestamp
    // so each trade is fed to exactly one chunk's aggregators.
    let mut trade_cursor: usize = if warmup_start == 0 {
        0
    } else {
        let prev_bar_ts = bars[warmup_start - 1 + bar_start_offset]
            .timestamp
            .timestamp_nanos_opt()
            .unwrap_or(0);
        trades.partition_point(|t| {
            t.timestamp.timestamp_nanos_opt().unwrap_or(0) <= prev_bar_ts
        })
    };
    let mut features: Vec<Vec<f32>> = Vec::with_capacity(emit_end - emit_start);

    for out_idx in warmup_start..emit_end {
        let bar_idx = out_idx + bar_start_offset;
        let bar = &bars[bar_idx];
        let bar_ts_ns = bar.timestamp.timestamp_nanos_opt().unwrap_or(0);
        let bar_ts_ns_u64 = bar_ts_ns.max(0) as u64;

        // ── Feed event-based aggregators with all trades up to this bar ──
        let mut buy_count: u64 = 0;
        let mut sell_count: u64 = 0;
        while trade_cursor < trades.len() {
            let trade = &trades[trade_cursor];
            let trade_ts_ns = trade.timestamp.timestamp_nanos_opt().unwrap_or(0);
            if trade_ts_ns > bar_ts_ns {
                break;
            }
            let trade_ts_u64 = trade_ts_ns.max(0) as u64;
            bouchaud.update(trade.is_buy, trade_ts_u64);
            hawkes.update(trade_ts_u64);
            order_events.record(trade_ts_u64, b'T');
            // SpreadDecomposition needs mid at trade time — approximate via
            // bar close (acceptable for ~8s bars where mid drift is small).
            spread_decomp.update(trade.price, bar.close, trade.is_buy);
            // Feed OFICalculator's VPIN bucket tracker
            ofi_calc.feed_trade(trade.price, trade.volume as u64, trade.is_buy);
            // PIN bar-level B/S counts
            if trade.is_buy {
                buy_count += 1;
            } else {
                sell_count += 1;
            }
            trade_cursor += 1;
        }

        // Bar-level log return
        let bar_ret = if bar_idx > 0 && bars[bar_idx - 1].close > 0.0 {
            (bar.close / bars[bar_idx - 1].close).ln()
        } else {
            0.0
        };
        return_moments.update(bar_ret);

        // PIN: feed bar-level buy/sell counts
        let pin_feats = pin.update_bar(buy_count, sell_count);

        // Snapshot lookup
        let curr_snap_idx = bar_snap_idx[bar_idx].min(snapshots.len().saturating_sub(1));
        let prev_snap_idx = if bar_idx > 0 {
            bar_snap_idx[bar_idx - 1].min(snapshots.len().saturating_sub(1))
        } else {
            curr_snap_idx
        };

        // Block computations
        let (
            ofi_per_level,
            log_gofi,
            bid_deltas,
            ask_deltas,
            slope_convex,
            microprice_feats,
            lob_pca_proj,
        ) = if !snapshots.is_empty() {
            let curr_snap = &snapshots[curr_snap_idx];
            let prev_snap = &snapshots[prev_snap_idx];
            let ofi_per = OFICalculator::calc_ofi_per_level(curr_snap, prev_snap);
            let log_g = OFICalculator::apply_log_gofi(ofi_per);
            let (bid_d, ask_d) = OFICalculator::calc_book_deltas_per_level(curr_snap, prev_snap);
            let sc = OFICalculator::calc_slope_and_convexity(curr_snap);
            let mp = microprice.update(curr_snap);
            let pca = lob_pca.update_and_project(curr_snap);
            (ofi_per, log_g, bid_d, ask_d, sc, mp, pca)
        } else {
            ([0.0; 5], [0.0; 5], [0.0; 5], [0.0; 5], [0.0; 4], [0.0; 8], [0.0; 3])
        };

        let kyle_lambdas = kyle.maybe_update(bar_ts_ns_u64, bar_ret, ofi_per_level);

        // Block M: frac-diff close at 3 d values
        let fd_a = sanitize_f64(fd_03.process(bar.close));
        let fd_b = sanitize_f64(fd_05.process(bar.close));
        let fd_c = sanitize_f64(fd_07.process(bar.close));

        // Block AA + EE: realized variance / moments
        let rv_decomp = return_moments.realized_variance_decomposition();
        let moments = return_moments.realized_moments();

        // Block U: VPIN bucket trajectory (stateful via ofi_calc.feed_trade above)
        let vpin_traj = ofi_calc.vpin_bucket_trajectory();

        // Block O: spread decomp (already EMA-updated by trade feeding above)
        let spread_feats = spread_decomp.features();

        // Block X: trailing trend scan over the last TREND_SCAN_MAX_HORIZON
        // bars (zero forward reads — see trend_scanning.rs module docstring).
        let trend_feats = if bar_idx + 1 >= TREND_SCAN_MAX_HORIZON {
            let trailing: Vec<f64> = bars
                [bar_idx + 1 - TREND_SCAN_MAX_HORIZON..=bar_idx]
                .iter()
                .map(|b| b.close)
                .collect();
            trend_scanner.scan(&trailing)
        } else {
            [0.0_f64; 5]
        };

        // Block BB + R: trade-flow-state features (already updated by trade feeding)
        let bouchaud_feats = bouchaud.features();
        let hawkes_feats = hawkes.features();

        // Block DD: seasonality residual (need a vol/count proxy)
        let bar_vol = if bar.close > 0.0 {
            (bar.high - bar.low) / bar.close
        } else {
            0.0
        };
        let bar_trade_count = (buy_count + sell_count) as f64;
        let seasonality_feats = seasonality_events.update(bar_vol, bar_trade_count, bar_ts_ns);

        // Block W: order events
        let order_event_feats = order_events.features();

        // ── Block A-E factory updates ──
        // Maintain the bar history VecDeque (used by Price and Statistical
        // extractors, which take a &VecDeque<OHLCVBar>).
        if bar_window.len() >= BAR_WINDOW {
            bar_window.pop_front();
        }
        bar_window.push_back(bar.clone());

        // Block A (15): Price features (Parkinson, GK, YZ, Hurst, fractal, ...)
        let price_feats = PriceFeatureExtractor::extract_all(&bar_window);
        // Block B (10): Volume features
        volume_extractor.update(bar);
        let volume_feats = volume_extractor
            .extract_features()
            .unwrap_or([0.0_f64; 10]);
        // Block C (7): Statistical features (entropy, autocorr, percentile)
        let stat_feats = StatisticalFeatureExtractor::extract_all(&bar_window);
        // Block D (5): ADX
        let adx_feats = adx_extractor.update(bar);
        // Block D' (5): Regime ADX
        let regime_feats = regime_adx.update(bar);
        // Block E (8): Time features (cyclic-encoded; updates internal state with price)
        let _ = time_extractor.update(bar.close);
        let time_feats = time_extractor.extract_features(bar.timestamp);

        // ── Assemble alpha row in fixed order ──
        let mut row = Vec::with_capacity(ALPHA_FEATURE_DIM);
        // Block A (0..15) — Price features
        for &v in &price_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block B (15..25) — Volume features
        for &v in &volume_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block C (25..32) — Statistical features
        for &v in &stat_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block D (32..37) — ADX
        for &v in &adx_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block D' (37..42) — Regime ADX
        for &v in &regime_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block E (42..50) — Time features (cyclic encoded)
        for &v in &time_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block F (50..55) — multi-level OFI
        for &v in &ofi_per_level {
            row.push(v as f32);
        }
        // Block G (55..60) — log-GOFI
        for &v in &log_gofi {
            row.push(v as f32);
        }
        // Block H (60..70) — per-level deltas (5 bid + 5 ask)
        for &v in &bid_deltas {
            row.push(v as f32);
        }
        for &v in &ask_deltas {
            row.push(v as f32);
        }
        // Block T (70..75) — multi-level Kyle's λ
        for &v in &kyle_lambdas {
            row.push(sanitize_f64(v) as f32);
        }
        // Block M (75..78) — frac-diff
        row.push(fd_a as f32);
        row.push(fd_b as f32);
        row.push(fd_c as f32);
        // Block AA (78..82) — RV decomp
        for &v in &rv_decomp {
            row.push(sanitize_f64(v) as f32);
        }
        // Block EE (82..86) — skew/kurt
        for &v in &moments {
            row.push(sanitize_f64(v) as f32);
        }
        // Block U (86..91) — VPIN trajectory
        for &v in &vpin_traj {
            row.push(v as f32);
        }
        // Block CC (91..95) — slope + convexity
        for &v in &slope_convex {
            row.push(sanitize_f64(v) as f32);
        }
        // Block N+V (95..103) — microprice features
        for &v in &microprice_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block O (103..107) — spread decomp
        for &v in &spread_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block X (107..112) — trend scanning
        for &v in &trend_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block Y (112..115) — LOB PCA
        for &v in &lob_pca_proj {
            row.push(sanitize_f64(v) as f32);
        }
        // Block BB (115..117) — Bouchaud
        for &v in &bouchaud_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block R (117..121) — Hawkes
        for &v in &hawkes_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block Z (121..124) — PIN
        for &v in &pin_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block DD (124..130) — seasonality + events
        for &v in &seasonality_feats {
            row.push(sanitize_f64(v) as f32);
        }
        // Block W (130..134) — order events
        for &v in &order_event_feats {
            row.push(sanitize_f64(v) as f32);
        }

        debug_assert_eq!(
            row.len(),
            ALPHA_FEATURE_DIM,
            "alpha row at out_idx={out_idx} has {} dims, expected {}",
            row.len(),
            ALPHA_FEATURE_DIM
        );

        // Suppress emission for the leading warmup bars; their job is to
        // saturate Hawkes excitation / Bouchaud EMA / frac-diff FIR / LOB PCA
        // / microprice / spread_decomp state before the first emit_start bar.
        if out_idx >= emit_start {
            features.push(row);
        }
    }

    tracing::info!(
        "alpha chunk[{}] done: rows={} elapsed={:.1}s",
        chunk_idx,
        features.len(),
        t_chunk_start.elapsed().as_secs_f64()
    );
    features
}

/// Coerce non-finite values to zero. alpha features feed an MLP that NaN-poisons
/// on first forward pass; trimming sentinels here is cheaper than per-batch
/// NaN-guards downstream.
fn sanitize_f64(v: f64) -> f64 {
    if v.is_finite() {
        v
    } else {
        0.0
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::OHLCVBar;
    use chrono::{TimeZone, Utc};

    fn make_bar(t_sec: i64, close: f64) -> OHLCVBar {
        OHLCVBar {
            timestamp: Utc.timestamp_opt(t_sec, 0).unwrap(),
            open: close,
            high: close * 1.001,
            low: close * 0.999,
            close,
            volume: 1000.0,
        }
    }

    #[test]
    fn test_extract_alpha_features_smoke_no_snapshots_no_trades() {
        // Synthetic 100-bar series; no MBP-10 or trade data → snapshot/trade
        // blocks all zero, but bar-level blocks (A, AA, EE, M, X) populate.
        let bars: Vec<OHLCVBar> = (0..100)
            .map(|i| make_bar(1_700_000_000 + i, 100.0 + (i as f64) * 0.01))
            .collect();
        let snapshots: Vec<Mbp10Snapshot> = vec![];
        let trades: Vec<Mbp10Trade> = vec![];

        let bar_start_offset = 50;
        let n_output = 40;
        let alpha = extract_alpha_features(&bars, &snapshots, &trades, bar_start_offset, n_output);

        assert_eq!(alpha.len(), n_output);
        for (i, row) in alpha.iter().enumerate() {
            assert_eq!(row.len(), ALPHA_FEATURE_DIM, "row {i} dim mismatch");
            for (j, &val) in row.iter().enumerate() {
                assert!(val.is_finite(), "non-finite at out_idx={i} dim={j}: {val}");
            }
        }
    }

    #[test]
    fn test_extract_alpha_features_blocks_a_e_populated() {
        // Stage 2 full wiring: Block A-E should now produce real values from
        // the ml-features factory extractors (not zero-filled placeholders).
        let bars: Vec<OHLCVBar> = (0..60)
            .map(|i| make_bar(1_700_000_000 + i, 100.0 + (i as f64).sin()))
            .collect();
        let alpha = extract_alpha_features(&bars, &[], &[], 30, 20);
        assert_eq!(alpha.len(), 20);
        assert_eq!(alpha[0].len(), ALPHA_FEATURE_DIM);

        // At least ONE non-zero value in each of Block A-E ranges proves the
        // factory extractors are firing on real bar data.
        let blocks: &[(usize, usize, &str)] = &[
            (0, 15, "A: Price"),
            (15, 25, "B: Volume"),
            (25, 32, "C: Statistical"),
            (32, 37, "D: ADX"),
            (37, 42, "D': RegimeADX"),
            (42, 50, "E: Time"),
        ];
        for &(start, end, name) in blocks {
            let any_nonzero = alpha[19][start..end].iter().any(|&x| x.abs() > 1e-12);
            assert!(
                any_nonzero,
                "Block {name} ({start}..{end}) all zero at last bar — extractor not wired"
            );
        }
    }
}
