//! Pure trading-domain analysis leaf (C1/C12): the post-run reductions over a
//! run's recorded streams that are functions of the recorded data alone —
//! R-based signal-quality metrics, the broker-independent position-event table,
//! and the multiple-comparison hurdle math. Split out of `aura-engine`'s
//! `report` module (issue #136) so the trading-domain arithmetic depends only on
//! `aura-core` + serde, not on the engine's trace-plumbing. Every type's serde
//! shape is byte-pinned by C18 goldens; the bodies are moved verbatim (no float
//! expression re-associated).

use aura_core::{Scalar, Timestamp};

/// Which selection objective produced the record (additive provenance, C23).
/// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials.
/// `PlateauMean` / `PlateauWorst` (cycle 0077) argmax the neighbourhood-smoothed
/// surface instead, so peak-vs-plateau runs stay distinguishable on this field.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SelectionMode {
    Argmax,
    PlateauMean,
    PlateauWorst,
}

/// Selection-provenance for a sweep winner. The selection RULE (`mode`) and its
/// ANNOTATION are orthogonal: `Argmax` carries the trials-deflation annotation
/// (`deflated_score` / `overfit_probability` / `n_resamples` / `block_len` /
/// `seed`); `Plateau*` carries the smoothing annotation (`neighbourhood_score` /
/// `n_neighbours`). Each annotation is `Option`, present iff its rule produced the
/// record; all are additive — recorded, never re-ranking (C23). A legacy line
/// (pre-0077) carries the deflation scalars as bare values that deserialize to
/// `Some` (serde default), so it still loads (C14/C18).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FamilySelection {
    pub selection_metric: String,
    pub n_trials: usize,
    pub raw_winner_metric: f64,
    pub mode: SelectionMode,
    // deflation annotation (present iff mode == Argmax with a deflation run)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deflated_score: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub overfit_probability: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub n_resamples: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub block_len: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seed: Option<u64>,
    // plateau annotation (present iff mode is Plateau*)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub neighbourhood_score: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub n_neighbours: Option<usize>,
}

/// Summary metrics reduced from a run's recorded streams — the `-> metrics`
/// half of C12's atomic sim unit. Pure function of the recorded streams.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RunMetrics {
    /// Final cumulative pip equity — the last value of the (cumulative)
    /// pip-equity curve. `0.0` if the curve is empty.
    pub total_pips: f64,
    /// Largest peak-to-trough drop on the cumulative pip curve:
    /// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the
    /// curve is monotonic non-decreasing or empty).
    pub max_drawdown: f64,
    /// Count of adjacent recorded bias samples whose sign differs (a zero
    /// bias normalizes to sign `0`, so flat is distinct from long/short).
    /// A turnover proxy: it counts long<->short reversals *and* transitions
    /// into/out of flat — the plain sign-change count over the bias series.
    /// The serde alias accepts the pre-rename `exposure_sign_flips` key so legacy
    /// `runs.jsonl` lines still deserialise (C14/C18 back-compat).
    #[serde(alias = "exposure_sign_flips")]
    pub bias_sign_flips: u64,
    /// Optional R metrics block. `None` for a pip-only run (and for legacy
    /// `runs.jsonl` written before this field existed — `serde(default)`); omitted from
    /// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk
    /// shape stays byte-unchanged (C14/C18 back-compat).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub r: Option<RMetrics>,
}

/// R-based signal-quality metrics, reduced from a `PositionManagement` dense
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries
/// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RMetrics {
    pub expectancy_r: f64,   // mean realised R over all trades (equal-weighted; headline)
    pub n_trades: u64,
    pub win_rate: f64,       // fraction with R > 0
    pub avg_win_r: f64,
    pub avg_loss_r: f64,
    pub profit_factor: f64,  // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
    pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
    pub n_open_at_end: u64,  // positions force-closed at window end (counted, not hidden)
    pub sqn: f64,            // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0
    /// (mean_R / sample-stdev_R) · √(min(n, SQN_CAP)): the n-normalized SQN
    /// ("SQN score", Van Tharp), turnover-robust vs. the raw `sqn`. n<2 or
    /// zero-variance -> 0.0.
    #[serde(default)]
    pub sqn_normalized: f64,
    pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty
    pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0]
    /// Realised R per closed trade, in trade order — an in-memory conduit for the
    /// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire
    /// shape is unchanged, and from `PartialEq` (below) so every existing
    /// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
    #[serde(skip)]
    pub trade_rs: Vec<f64>,
}

impl PartialEq for RMetrics {
    fn eq(&self, o: &Self) -> bool {
        self.expectancy_r == o.expectancy_r
            && self.n_trades == o.n_trades
            && self.win_rate == o.win_rate
            && self.avg_win_r == o.avg_win_r
            && self.avg_loss_r == o.avg_loss_r
            && self.profit_factor == o.profit_factor
            && self.max_r_drawdown == o.max_r_drawdown
            && self.n_open_at_end == o.n_open_at_end
            && self.sqn == o.sqn
            && self.sqn_normalized == o.sqn_normalized
            && self.net_expectancy_r == o.net_expectancy_r
            && self.conviction_terciles_r == o.conviction_terciles_r
        // trade_rs deliberately excluded
    }
}

// Dense `PositionManagement` record column indices — the lockstep contract with
// aura-std's FIELD_NAMES/RECORD_KINDS. The record crosses the crate boundary as a
// type-erased `Scalar` vector (C4 SoA), so it is read positionally, not by importing
// the producer's types; the layout is shared by convention and `r_sma_e2e.rs`
// guards that it matches.
mod r_col {
    pub const CLOSED: usize = 0;
    pub const REALIZED_R: usize = 1;
    pub const DIRECTION: usize = 4;
    // entry/stop define the latched distance (1R). Since the net-of-cost fold reads the
    // cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers
    // the distance), this crate's reductions no longer touch these two columns; they stay in
    // the documented layout contract and are exercised by the test fixtures.
    #[allow(dead_code)]
    pub const ENTRY_PRICE: usize = 6;
    #[allow(dead_code)]
    pub const STOP_PRICE: usize = 7;
    pub const CONVICTION_AT_ENTRY: usize = 9;
    pub const SIZE: usize = 10;
    pub const OPEN: usize = 11;
    pub const UNREALIZED_R: usize = 12;
}

// Cost-model record column indices — the lockstep contract with aura-std's
// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`).
// Read positionally across the crate boundary as a type-erased `Scalar` vector
// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the
// window-end open trade's would-be cost (col 2); the running sum (col 1) is not
// folded, so it is intentionally not named here.
mod cost_col {
    pub const COST_IN_R: usize = 0;
    pub const OPEN_COST_IN_R: usize = 2;
}

/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN
/// score"): capping n stops the single-number objective rewarding sheer turnover.
const SQN_CAP: u64 = 100;

/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
pub fn summarize_r(
    record: &[(Timestamp, Vec<Scalar>)],
    cost: &[(Timestamp, Vec<Scalar>)],
) -> RMetrics {
    // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the
    // per-trade cost-in-R the cost-model graph supplied (read positionally from the
    // co-temporal `cost` stream). The ledger is the closed rows; a position still open on
    // the last row is force-closed at its unrealized R (a window-end trade).
    struct Trade {
        r: f64,
        bias_abs: f64,
        cost: f64,
    }
    // Positional join: the cost stream is co-temporal 1:1 with the record (both recorded
    // per cycle off the same executor cadence — a cost node emits exactly when PM emits),
    // so `record[i]` and `cost[i]` are the same cycle. A closed row `i` reads `cost[i]`'s
    // `cost_in_r` (col 0); the window-end open last row reads its `open_cost_in_r` (col 2).
    // An empty `cost` slice ⇒ every `.get(i)` is `None` ⇒ cost `0.0` (the gross-R baseline).
    // Positional avoids the shared-timestamp collision a `ts`-keyed map would have (C4:
    // same `ts` can be two cycles).
    let mut trades: Vec<Trade> = Vec::new();
    for (i, (_, row)) in record.iter().enumerate() {
        if row[r_col::CLOSED].as_bool() {
            let c = cost.get(i).map(|(_, cr)| cr[cost_col::COST_IN_R].as_f64()).unwrap_or(0.0);
            trades.push(Trade {
                r: row[r_col::REALIZED_R].as_f64(),
                bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(),
                cost: c,
            });
        }
    }
    let mut n_open_at_end = 0u64;
    if let Some((_, last)) = record.last()
        && last[r_col::OPEN].as_bool()
    {
        let c = cost
            .get(record.len() - 1)
            .map(|(_, cr)| cr[cost_col::OPEN_COST_IN_R].as_f64())
            .unwrap_or(0.0);
        trades.push(Trade {
            r: last[r_col::UNREALIZED_R].as_f64(),
            bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(),
            cost: c,
        });
        n_open_at_end = 1;
    }
    let n = trades.len() as u64;
    if n == 0 {
        return RMetrics {
            expectancy_r: 0.0,
            n_trades: 0,
            win_rate: 0.0,
            avg_win_r: 0.0,
            avg_loss_r: 0.0,
            profit_factor: 0.0,
            max_r_drawdown: 0.0,
            n_open_at_end: 0,
            sqn: 0.0,
            sqn_normalized: 0.0,
            net_expectancy_r: 0.0,
            conviction_terciles_r: [0.0; 3],
            trade_rs: Vec::new(),
        };
    }
    let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
    let sum: f64 = rs.iter().sum();
    let mean = sum / n as f64;
    let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
    let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
    let sum_win: f64 = wins.iter().sum();
    let sum_loss: f64 = losses.iter().sum(); // <= 0
    let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
    // by-trade cumulative-R drawdown
    let mut peak = f64::NEG_INFINITY;
    let mut cum = 0.0;
    let mut max_dd = 0.0_f64;
    for &r in &rs {
        cum += r;
        if cum > peak {
            peak = cum;
        }
        let dd = peak - cum;
        if dd > max_dd {
            max_dd = dd;
        }
    }
    // SQN = √n · mean / sample-stdev (raw, Van Tharp). SQN100 = √(min(n,SQN_CAP)) ·
    // mean / sd — the same dispersion ratio with the trade count capped
    // (turnover-robust). n < 2 or zero variance -> 0.0 for both (dispersion undefined).
    let (sqn, sqn_normalized) = if n < 2 {
        (0.0, 0.0)
    } else {
        let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
        let sd = var.sqrt();
        if sd > 0.0 {
            // raw sqn kept bit-identical to the pre-0067 expression (sqrt(n)*mean/sd,
            // not factored via a shared ratio) so the existing metric is byte-unchanged.
            (
                (n as f64).sqrt() * mean / sd,
                (n.min(SQN_CAP) as f64).sqrt() * mean / sd,
            )
        } else {
            (0.0, 0.0)
        }
    };
    // net-of-cost: subtract the cost-model's per-trade cost-in-R (from the cost stream;
    // an empty stream is the gross-R baseline — every trade's cost is 0.0).
    let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
    let net_expectancy_r = net_sum / n as f64;
    // conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
    // near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
    let conviction_terciles_r = if n < 3 {
        [0.0; 3]
    } else {
        let mut by_conv: Vec<&Trade> = trades.iter().collect();
        by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs));
        let nn = by_conv.len();
        let mut out = [0.0; 3];
        for (i, slot) in out.iter_mut().enumerate() {
            let lo = i * nn / 3;
            let hi = (i + 1) * nn / 3;
            let bucket = &by_conv[lo..hi];
            *slot = if bucket.is_empty() {
                0.0
            } else {
                bucket.iter().map(|t| t.r).sum::<f64>() / bucket.len() as f64
            };
        }
        out
    };
    RMetrics {
        expectancy_r: mean,
        n_trades: n,
        win_rate: wins.len() as f64 / n as f64,
        avg_win_r: avg(&wins),
        avg_loss_r: avg(&losses),
        profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
        max_r_drawdown: max_dd,
        n_open_at_end,
        sqn,
        sqn_normalized,
        net_expectancy_r,
        conviction_terciles_r,
        trade_rs: rs,
    }
}

/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a
/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit
/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte —
/// deliberately copied, not factored, so neither pinned-float expression shifts under
/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in
/// lockstep; the only guard that they agree is the cross-reducer equality test
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
/// without the other and that test is the sole tripwire. The
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
/// `net_expectancy_r = expectancy_r` (exact under the cost = 0 invariant),
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
/// input -> a well-defined all-zero `RMetrics`.
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
    let n = rs.len() as u64;
    if n == 0 {
        return RMetrics {
            expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
            avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0,
            n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0,
            conviction_terciles_r: [0.0; 3], trade_rs: Vec::new(),
        };
    }
    let sum: f64 = rs.iter().sum();
    let mean = sum / n as f64;
    let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
    let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
    let sum_win: f64 = wins.iter().sum();
    let sum_loss: f64 = losses.iter().sum();
    let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
    let mut peak = f64::NEG_INFINITY;
    let mut cum = 0.0;
    let mut max_dd = 0.0_f64;
    for &r in rs {
        cum += r;
        if cum > peak { peak = cum; }
        let dd = peak - cum;
        if dd > max_dd { max_dd = dd; }
    }
    let (sqn, sqn_normalized) = if n < 2 {
        (0.0, 0.0)
    } else {
        let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
        let sd = var.sqrt();
        if sd > 0.0 {
            ((n as f64).sqrt() * mean / sd, (n.min(SQN_CAP) as f64).sqrt() * mean / sd)
        } else {
            (0.0, 0.0)
        }
    };
    RMetrics {
        expectancy_r: mean,
        n_trades: n,
        win_rate: wins.len() as f64 / n as f64,
        avg_win_r: avg(&wins),
        avg_loss_r: avg(&losses),
        profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
        max_r_drawdown: max_dd,
        n_open_at_end: 0,
        sqn,
        sqn_normalized,
        net_expectancy_r: mean, // cost = 0 -> net == gross (frictionless)
        conviction_terciles_r: [0.0; 3],
        trade_rs: Vec::new(),
    }
}

/// Derive the broker-independent position-event table from a `PositionManagement`
/// dense record (read positionally, C7 SoA), as the first difference of the
/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the
/// instant-fill backtest). Pure (C1); no look-ahead — each event's `event_ts` is
/// its own cycle (C2). `instrument_id` is supplied by the caller (the engine never
/// imports an instrument spec from `aura-ingest`). A reversal emits `Close` then the
/// opposite open at one `event_ts` (close first); a position still open on the last
/// row emits its open with no synthetic `Close` (the table records actual executed events —
/// unlike `summarize_r`, which force-closes for the R metric).
pub fn derive_position_events(
    record: &[(Timestamp, Vec<Scalar>)],
    instrument_id: i64,
) -> Vec<PositionEvent> {
    // The derive's single-position book (in_flight is structurally 0).
    struct Book {
        position_id: i64,
        volume: f64,
    }
    let mut out: Vec<PositionEvent> = Vec::new();
    let mut book: Option<Book> = None;
    let mut next_id: i64 = 0;
    for (ts, row) in record {
        // 1) close first: the book held into this cycle exited this cycle.
        if row[r_col::CLOSED].as_bool()
            && let Some(b) = book.take()
        {
            out.push(PositionEvent {
                event_ts: *ts,
                action: PositionAction::Close,
                position_id: b.position_id,
                instrument_id,
                volume: b.volume,
            });
        }
        // 2) then open: a position is open at cycle end the book is not tracking.
        if row[r_col::OPEN].as_bool() && book.is_none() {
            let dir = row[r_col::DIRECTION].as_i64();
            let volume = row[r_col::SIZE].as_f64();
            let action = if dir >= 0 { PositionAction::Buy } else { PositionAction::Sell };
            out.push(PositionEvent {
                event_ts: *ts,
                action,
                position_id: next_id,
                instrument_id,
                volume,
            });
            book = Some(Book { position_id: next_id, volume });
            next_id += 1;
        }
    }
    out
}

/// The three position-event actions (C10). Direction IS the action; volume is
/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the
/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(into = "i64", try_from = "i64")]
pub enum PositionAction {
    Buy,
    Sell,
    Close,
}

impl From<PositionAction> for i64 {
    fn from(a: PositionAction) -> i64 {
        match a {
            PositionAction::Buy => 0,
            PositionAction::Sell => 1,
            PositionAction::Close => 2,
        }
    }
}

impl TryFrom<i64> for PositionAction {
    type Error = String;
    fn try_from(v: i64) -> Result<Self, Self::Error> {
        match v {
            0 => Ok(PositionAction::Buy),
            1 => Ok(PositionAction::Sell),
            2 => Ok(PositionAction::Close),
            other => Err(format!("invalid PositionAction i64: {other}")),
        }
    }
}

/// One row of C10's derived position-event table — the broker-independent audit
/// view (the first difference of the exposure state). A post-run value type
/// (sibling of [`RunMetrics`]), NOT a per-`eval` node output (C8). Multiple events
/// may share one `event_ts` (a reversal: Close then open, close-before-open). No
/// `open_ts` — a position's open time is its opening event's `event_ts`.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PositionEvent {
    pub event_ts: Timestamp,
    pub action: PositionAction,
    /// Monotonic, assigned at open. A Close references an existing `position_id`.
    pub position_id: i64,
    pub instrument_id: i64,
    /// Lots, unsigned. A partial close carries its own (smaller) volume.
    pub volume: f64,
}

/// Inverse standard-normal CDF (quantile function), Acklam's rational
/// approximation — absolute error < ~1.2e-9 over `p ∈ (0,1)`. Pure (C1).
/// Callers pass strictly-interior `p` (`k >= 2` keeps the argument off the
/// boundaries); the boundary branches return ±inf as a defined limit.
// Acklam's published coefficients verbatim; some carry more decimals than an
// f64 holds (clippy::excessive_precision) — kept literal as reference constants.
#[allow(clippy::excessive_precision)]
pub fn inv_norm_cdf(p: f64) -> f64 {
    const A: [f64; 6] = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
                          1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
    const B: [f64; 5] = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
                          6.680131188771972e+01, -1.328068155288572e+01];
    const C: [f64; 6] = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
                         -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
    const D: [f64; 4] = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
                         3.754408661907416e+00];
    const P_LOW: f64 = 0.02425;
    if p <= 0.0 { return f64::NEG_INFINITY; }
    if p >= 1.0 { return f64::INFINITY; }
    if p < P_LOW {
        let q = (-2.0 * p.ln()).sqrt();
        (((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
            / ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
    } else if p <= 1.0 - P_LOW {
        let q = p - 0.5;
        let r = q * q;
        (((((A[0]*r + A[1])*r + A[2])*r + A[3])*r + A[4])*r + A[5]) * q
            / (((((B[0]*r + B[1])*r + B[2])*r + B[3])*r + B[4])*r + 1.0)
    } else {
        let q = (-2.0 * (1.0 - p).ln()).sqrt();
        -(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
            / ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
    }
}

/// Expected maximum of `k` i.i.d. standard normals — the hurdle a search of `k`
/// configurations clears by chance alone. `k <= 1` -> `0.0` (a single trial has no
/// multiple-comparison inflation, and guards the `Φ⁻¹(1 - 1/k) -> Φ⁻¹(0)`
/// divergence). For `k >= 2`:
/// `(1 - γ)·Φ⁻¹(1 - 1/k) + γ·Φ⁻¹(1 - 1/(k·e))`, `γ = 0.5772156649…`. Pure (C1).
pub fn expected_max_of_normals(k: usize) -> f64 {
    if k <= 1 {
        return 0.0;
    }
    const GAMMA: f64 = 0.577_215_664_901_532_9;
    let kf = k as f64;
    (1.0 - GAMMA) * inv_norm_cdf(1.0 - 1.0 / kf)
        + GAMMA * inv_norm_cdf(1.0 - 1.0 / (kf * std::f64::consts::E))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inv_norm_cdf_matches_known_quantiles() {
        assert!((inv_norm_cdf(0.975) - 1.959964).abs() < 1e-3);
        assert!(inv_norm_cdf(0.5).abs() < 1e-9);
        assert!((inv_norm_cdf(0.1) + inv_norm_cdf(0.9)).abs() < 1e-9); // symmetry
    }

    #[test]
    fn expected_max_of_normals_is_zero_at_one_and_monotone() {
        assert_eq!(expected_max_of_normals(1), 0.0);
        assert_eq!(expected_max_of_normals(0), 0.0);
        let (e2, e4, e10, e100) = (
            expected_max_of_normals(2), expected_max_of_normals(4),
            expected_max_of_normals(10), expected_max_of_normals(100),
        );
        assert!(e2 < e4 && e4 < e10 && e10 < e100); // strictly increasing
        assert!((1.0..1.1).contains(&e4));           // ~1.05 (not the √(2 ln 4)=1.66 asymptote)
        assert!((2.4..2.7).contains(&e100));         // ~2.53
    }

    #[test]
    fn position_action_round_trips_through_i64() {
        for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] {
            let n: i64 = a.into();
            assert_eq!(PositionAction::try_from(n), Ok(a));
        }
        assert_eq!(i64::from(PositionAction::Buy), 0);
        assert_eq!(i64::from(PositionAction::Sell), 1);
        assert_eq!(i64::from(PositionAction::Close), 2);
    }

    #[test]
    fn position_action_rejects_out_of_range_i64() {
        assert!(PositionAction::try_from(3).is_err());
        assert!(PositionAction::try_from(-1).is_err());
    }

    #[test]
    fn position_event_serde_round_trips_with_bare_int_action() {
        let ev = PositionEvent {
            event_ts: Timestamp(42),
            action: PositionAction::Sell,
            position_id: 7,
            instrument_id: 3,
            volume: 0.5,
        };
        let json = serde_json::to_string(&ev).expect("serialize");
        // action encodes as a bare integer (C7 scalar shape), not a tagged enum
        assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}");
        let back: PositionEvent = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, ev);
    }

    #[test]
    fn position_events_may_share_one_event_ts_on_reversal() {
        // a stop-and-reverse: Close then open at the SAME event_ts (close-before-open).
        let ts = Timestamp(100);
        let close = PositionEvent {
            event_ts: ts, action: PositionAction::Close,
            position_id: 1, instrument_id: 3, volume: 0.5,
        };
        let open = PositionEvent {
            event_ts: ts, action: PositionAction::Sell,
            position_id: 2, instrument_id: 3, volume: 0.5,
        };
        let table = [close, open];
        assert_eq!(table[0].event_ts, table[1].event_ts);
        assert_eq!(table[0].action, PositionAction::Close);
        assert_eq!(table[1].action, PositionAction::Sell);
    }

    // Build a minimal dense record row: only the columns summarize_r reads matter.
    // The four written slots reference `r_col::*` (not bare literals) so the helper
    // tracks the same constants it exists to test — a literal would mask drift in them.
    fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<Scalar>) {
        let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
        v[r_col::CLOSED] = Scalar::bool(closed);
        v[r_col::REALIZED_R] = Scalar::f64(realized);
        v[r_col::OPEN] = Scalar::bool(open);
        v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
        (Timestamp(0), v)
    }

    // A fuller closed-trade dense row: also sets entry_price (6), stop_price (7) and
    // conviction_at_entry (9) — the geometry summarize_r recovers latched_dist and
    // conviction from. Width up to UNREALIZED_R+1 (summarize_r never reads col 13).
    fn r_row_full(realized: f64, entry: f64, stop: f64, bias_abs: f64) -> (Timestamp, Vec<Scalar>) {
        let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
        v[r_col::CLOSED] = Scalar::bool(true);
        v[r_col::REALIZED_R] = Scalar::f64(realized);
        v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
        v[r_col::STOP_PRICE] = Scalar::f64(stop);
        v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(bias_abs);
        v[r_col::OPEN] = Scalar::bool(false);
        (Timestamp(0), v)
    }

    // A 14-wide PositionManagement dense row carrying BOTH the closed-trade geometry
    // (entry/stop/conviction) AND the open-at-end fields (open/unrealized_r) — the one
    // fixture the cost-stream subsumption test needs (`r_row_full` forces closed=true, so
    // it cannot build a window-end open row). Only the columns summarize_r reads are set;
    // the rest default to f64(0.0) (never read), mirroring the existing row helpers.
    fn row14(closed: bool, r: f64, entry: f64, stop: f64, conv: f64, open: bool, unreal: f64) -> Vec<Scalar> {
        let mut v = vec![Scalar::f64(0.0); 14];
        v[r_col::CLOSED] = Scalar::bool(closed);
        v[r_col::REALIZED_R] = Scalar::f64(r);
        v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
        v[r_col::STOP_PRICE] = Scalar::f64(stop);
        v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(conv);
        v[r_col::OPEN] = Scalar::bool(open);
        v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
        v
    }
    #[test]
    fn summarize_r_is_zero_on_empty() {
        let m = summarize_r(&[], &[]);
        assert_eq!(m.n_trades, 0);
        assert_eq!(m.expectancy_r, 0.0);
        assert_eq!(m.max_r_drawdown, 0.0);
        assert_eq!(m.sqn, 0.0);
        assert_eq!(m.sqn_normalized, 0.0);
        assert_eq!(m.net_expectancy_r, 0.0);
        assert_eq!(m.conviction_terciles_r, [0.0; 3]);
    }
    #[test]
    fn summarize_r_expectancy_winrate_profit_factor() {
        // three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3.
        let rec = vec![
            r_row(true, 2.0, true, 0.0),
            r_row(false, 0.0, true, 0.0),
            r_row(true, -1.0, true, 0.0),
            r_row(true, 1.0, false, 0.0), // last row not open
        ];
        let m = summarize_r(&rec, &[]);
        assert_eq!(m.n_trades, 3);
        assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
        assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
        assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1
        assert_eq!(m.n_open_at_end, 0);
    }
    #[test]
    fn summarize_r_force_closes_open_position_at_window_end() {
        // one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
        let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
        let m = summarize_r(&rec, &[]);
        assert_eq!(m.n_trades, 2);
        assert_eq!(m.n_open_at_end, 1);
        assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
    }
    #[test]
    fn summarize_r_max_drawdown_on_by_trade_curve() {
        // cum: +3, +1 (dd 2), +4 -> max dd = 2.
        let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
        assert!((summarize_r(&rec, &[]).max_r_drawdown - 2.0).abs() < 1e-9);
    }

    #[test]
    fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() {
        // R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1;
        // sqn = sqrt(4)*0.5/1 = 1.0.
        let rec = vec![
            r_row(true, 1.0, false, 0.0),
            r_row(true, 1.0, false, 0.0),
            r_row(true, 1.0, false, 0.0),
            r_row(true, -1.0, false, 0.0),
        ];
        let m = summarize_r(&rec, &[]);
        assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn);
    }

    #[test]
    fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() {
        // n < 2 -> 0
        assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], &[]).sqn, 0.0);
        // identical R -> zero variance -> 0
        let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)];
        assert_eq!(summarize_r(&flat, &[]).sqn, 0.0);
    }

    #[test]
    fn summarize_r_sqn_normalized_caps_trade_count_at_100() {
        // 144 closed trades alternating R = 1.0 / 2.0 (nonzero variance, sd > 0).
        // raw sqn = √144·q, SQN100 = √(min(144,100))·q = √100·q for the SAME ratio
        // q = mean/sd, so sqn / sqn_normalized = √(144/100) = 1.2, independent of q.
        let rec: Vec<_> = (0..144)
            .map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0))
            .collect();
        let m = summarize_r(&rec, &[]);
        assert_eq!(m.n_trades, 144);
        assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized);
        assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100");
        assert!(
            (m.sqn / m.sqn_normalized - 1.2).abs() < 1e-9,
            "sqn / sqn_normalized = √(144/100) = 1.2; got {}",
            m.sqn / m.sqn_normalized
        );
    }

    #[test]
    fn summarize_r_sqn_normalized_equals_raw_sqn_below_cap() {
        // n = 4 <= 100 -> cap inactive -> SQN100 == raw sqn (same ledger as
        // summarize_r_sqn_is_sqrt_n_mean_over_stdev, where sqn = 1.0).
        let rec = vec![
            r_row(true, 1.0, false, 0.0),
            r_row(true, 1.0, false, 0.0),
            r_row(true, 1.0, false, 0.0),
            r_row(true, -1.0, false, 0.0),
        ];
        let m = summarize_r(&rec, &[]);
        assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn");
        assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here");
    }

    #[test]
    fn summarize_r_conviction_terciles_order_by_bias() {
        // Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three
        // terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5.
        let calibrated = vec![
            r_row_full(-2.0, 100.0, 99.0, 0.1),
            r_row_full(-1.0, 100.0, 99.0, 0.2),
            r_row_full(0.0, 100.0, 99.0, 0.5),
            r_row_full(0.0, 100.0, 99.0, 0.6),
            r_row_full(1.0, 100.0, 99.0, 0.9),
            r_row_full(2.0, 100.0, 99.0, 1.0),
        ];
        let m = summarize_r(&calibrated, &[]);
        assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r);
        assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r);
        assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated");

        // Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the
        // metric reads |bias|, not trade order. Same R multiset, |bias| reversed.
        let anti = vec![
            r_row_full(2.0, 100.0, 99.0, 0.1),
            r_row_full(1.0, 100.0, 99.0, 0.2),
            r_row_full(0.0, 100.0, 99.0, 0.5),
            r_row_full(0.0, 100.0, 99.0, 0.6),
            r_row_full(-1.0, 100.0, 99.0, 0.9),
            r_row_full(-2.0, 100.0, 99.0, 1.0),
        ];
        let a = summarize_r(&anti, &[]);
        assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r);
    }

    #[test]
    fn summarize_r_conviction_terciles_zero_under_three_trades() {
        let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)];
        assert_eq!(summarize_r(&rec, &[]).conviction_terciles_r, [0.0; 3]);
    }

    #[test]
    fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() {
        // one closed trade: R=+2, entry 100, stop 90 -> latched 10. A ConstantCost(1.0)
        // node (1.0 price units) emits cost_in_r = 1.0/10 = 0.1 on the closed row (col 0).
        // gross E[R]=2.0, net = 1.9. The empty stream is the gross baseline (net == gross).
        let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)];
        let gross = summarize_r(&rec, &[]);
        // producer 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]: closed -> 0.1 / 0.1 / 0.
        let cost = vec![(Timestamp(0), vec![Scalar::f64(0.1), Scalar::f64(0.1), Scalar::f64(0.0)])];
        let net = summarize_r(&rec, &cost);
        assert!((gross.expectancy_r - 2.0).abs() < 1e-9);
        assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross");
        assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy");
        assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
    }

    /// Property: `summarize_r` folding the cost stream reproduces the retired scalar
    /// `round_trip_cost` argument EXACTLY — `net_expectancy_r == mean(rᵢ − costᵢ)` over
    /// BOTH the closed trade (cost from the stream's col 0) and the window-end open trade
    /// (cost from col 2), with the cost-in-R taken straight from the co-temporal stream a
    /// `ConstantCost` node emits (not recomputed from latched). Exercises a nonzero cost,
    /// so it proves exact subsumption — not merely the cost=0 baseline.
    #[test]
    fn summarize_r_folds_cost_stream_exactly_like_the_scalar_over_all_trades() {
        // Trade 1: closed, entry 100 stop 96 (latched 4), realized R = 1.0.
        // Trade 2: still open at window end, entry 100 stop 98 (latched 2), unrealized R = 0.5.
        let c = 2.0_f64;
        let record = vec![
            (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)),
            (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)),
        ];
        // Cost stream the ConstantCost(2.0) node emits, co-temporal — the producer's
        // 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r].
        let cost = vec![
            // closed row: 0.5 charged on close (col 0), cum 0.5, no open cost.
            (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]),
            // open row: no close cost, cum unchanged, would-be open cost 1.0 (col 2).
            (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]),
        ];
        let m = summarize_r(&record, &cost);
        // gross E[R] = mean(1.0, 0.5) = 0.75; net = mean(1.0-0.5, 0.5-1.0) = mean(0.5,-0.5) = 0.0
        assert!((m.expectancy_r - 0.75).abs() < 1e-12);
        assert!((m.net_expectancy_r - 0.0).abs() < 1e-12);
    }

    // One PositionManagement dense record row for the derive tests: only the columns
    // derive_position_events reads (closed, direction, size, open) are set; the rest
    // default to 0. Distinct per-row timestamps (the derive keys events on the row's
    // own cycle, not the entry_ts column).
    fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec<Scalar>) {
        let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
        v[r_col::CLOSED] = Scalar::bool(closed);
        v[r_col::DIRECTION] = Scalar::i64(dir);
        v[r_col::SIZE] = Scalar::f64(size);
        v[r_col::OPEN] = Scalar::bool(open);
        (Timestamp(ts), v)
    }

    #[test]
    fn derive_reversal_emits_close_then_opposite_open_at_one_ts() {
        let rec = vec![
            pm_row(10, false, 1, 2.0, true),   // open long
            pm_row(20, true, -1, 3.0, true),   // reversal: close long, reopen short
            pm_row(30, true, -1, 3.0, false),  // close short, flat
        ];
        let ev = derive_position_events(&rec, 42);
        assert_eq!(ev.len(), 4);
        assert_eq!(ev[0].action, PositionAction::Buy);
        assert_eq!(ev[0].event_ts, Timestamp(10));
        assert_eq!(ev[0].position_id, 0);
        assert_eq!(ev[0].volume, 2.0);
        assert_eq!(ev[0].instrument_id, 42);
        assert_eq!(ev[1].action, PositionAction::Close);
        assert_eq!(ev[1].position_id, 0);
        assert_eq!(ev[1].event_ts, Timestamp(20));
        assert_eq!(ev[1].volume, 2.0); // close sizes the actual (old) book, not the new leg
        assert_eq!(ev[2].action, PositionAction::Sell);
        assert_eq!(ev[2].position_id, 1);
        assert_eq!(ev[2].event_ts, Timestamp(20)); // same instant as the close
        assert_eq!(ev[2].volume, 3.0);
        assert_eq!(ev[3].action, PositionAction::Close);
        assert_eq!(ev[3].position_id, 1);
        assert_eq!(ev[3].event_ts, Timestamp(30));
    }

    #[test]
    fn derive_normal_open_hold_close_lifecycle() {
        let rec = vec![
            pm_row(1, false, 1, 1.0, true),  // open long
            pm_row(2, false, 1, 1.0, true),  // hold (no event)
            pm_row(3, true, 1, 1.0, false),  // close to flat
        ];
        let ev = derive_position_events(&rec, 9);
        assert_eq!(ev.len(), 2);
        assert_eq!(ev[0].action, PositionAction::Buy);
        assert_eq!(ev[0].event_ts, Timestamp(1));
        assert_eq!(ev[1].action, PositionAction::Close);
        assert_eq!(ev[1].event_ts, Timestamp(3));
        assert_eq!(ev[1].position_id, 0);
    }

    #[test]
    fn derive_short_entry_emits_sell() {
        let ev = derive_position_events(&[pm_row(5, false, -1, 1.0, true)], 0);
        assert_eq!(ev.len(), 1);
        assert_eq!(ev[0].action, PositionAction::Sell);
        assert_eq!(ev[0].position_id, 0);
    }

    #[test]
    fn derive_window_end_open_has_no_synthetic_close() {
        // opened and never closed in-window -> open event only, no Close.
        let rec = vec![
            pm_row(1, false, 1, 1.0, true),
            pm_row(2, false, 1, 1.0, true), // still open on the last row
        ];
        let ev = derive_position_events(&rec, 0);
        assert_eq!(ev.len(), 1);
        assert_eq!(ev[0].action, PositionAction::Buy);
    }

    #[test]
    fn derive_empty_record_is_empty_table() {
        let rec: Vec<(Timestamp, Vec<Scalar>)> = vec![];
        assert!(derive_position_events(&rec, 0).is_empty());
    }

    #[test]
    fn derive_position_ids_are_monotonic_across_trades() {
        let rec = vec![
            pm_row(1, false, 1, 1.0, true),   // open id0
            pm_row(2, true, 1, 1.0, false),   // close id0
            pm_row(3, false, -1, 1.0, true),  // open id1 (short)
            pm_row(4, true, -1, 1.0, false),  // close id1
        ];
        let ev = derive_position_events(&rec, 0);
        assert_eq!(ev.len(), 4);
        assert_eq!(ev[0].position_id, 0);
        assert_eq!(ev[1].position_id, 0);
        assert_eq!(ev[2].position_id, 1);
        assert_eq!(ev[3].position_id, 1);
    }

    #[test]
    fn runmetrics_with_r_block_round_trips() {
        let m = RunMetrics {
            total_pips: 3.0,
            max_drawdown: 1.0,
            bias_sign_flips: 2,
            r: Some(RMetrics {
                expectancy_r: 0.5,
                n_trades: 4,
                win_rate: 0.5,
                avg_win_r: 1.5,
                avg_loss_r: -0.5,
                profit_factor: 3.0,
                max_r_drawdown: 0.5,
                n_open_at_end: 1,
                sqn: 1.0,
                sqn_normalized: 1.0,
                net_expectancy_r: 0.4,
                conviction_terciles_r: [-0.5, 0.5, 1.5],
                trade_rs: Vec::new(),
            }),
        };
        let json = serde_json::to_string(&m).expect("serialize");
        assert!(json.contains("\"r\":{"), "r block present when Some: {json}");
        let back: RunMetrics = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back, m);
    }

    #[test]
    fn rmetrics_deserializes_without_sqn_normalized_field_to_zero() {
        // C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized
        // existed) lacks the field; serde(default) must fill it with 0.0.
        let legacy = r#"{"expectancy_r":1.0,"n_trades":4,"win_rate":0.5,"avg_win_r":1.5,"avg_loss_r":-0.5,"profit_factor":3.0,"max_r_drawdown":0.5,"n_open_at_end":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#;
        let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes");
        assert_eq!(m.sqn_normalized, 0.0);
        assert_eq!(m.sqn, 1.0);
    }

    #[test]
    fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() {
        // a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it.
        let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#;
        let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises");
        assert_eq!(m.bias_sign_flips, 3);
        // new output serialises the NEW key, and the old key is gone from output.
        let json = serde_json::to_string(&m).unwrap();
        assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}");
        assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}");
    }

    #[test]
    fn legacy_runmetrics_without_r_field_deserialises_to_none() {
        // a pre-`r` runs.jsonl line: no `r` key at all.
        let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#;
        let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises");
        assert_eq!(m.r, None);
        assert_eq!(m.total_pips, 12.0);
    }

    #[test]
    fn runmetrics_none_r_is_omitted_from_json() {
        let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None };
        let json = serde_json::to_string(&m).expect("serialize");
        assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}");
    }

    #[test]
    fn summarize_r_populates_trade_rs_in_trade_order() {
        // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
        // record; trade_rs must carry [2.0, -1.0] in trade order.
        let rec = pm_record_two_closed_trades(); // helper below
        let m = summarize_r(&rec, &[]);
        assert_eq!(m.trade_rs, vec![2.0, -1.0]);
        assert_eq!(m.n_trades, 2);
    }

    #[test]
    fn summarize_r_empty_record_has_empty_trade_rs() {
        let m = summarize_r(&[], &[]);
        assert!(m.trade_rs.is_empty());
        assert_eq!(m.n_trades, 0);
    }

    /// Property: a position still open on the last row is folded into the trade
    /// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
    /// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
    /// This is the one case where the two reducers' inputs differ in meaning — it
    /// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
    /// so the two must agree on the R-distribution arithmetic for an
    /// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5].
    #[test]
    fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
        let rec = pm_record_closed_then_open_at_end();
        let m = summarize_r(&rec, &[]);
        assert_eq!(m.trade_rs, vec![2.0, 0.5]);
        assert_eq!(m.n_trades, 2);
        assert_eq!(m.n_open_at_end, 1);
        // Feed the open-at-end pooled series through the flat reducer: the
        // R-distribution fields (the verbatim-copied arithmetic) must agree.
        let pooled = r_metrics_from_rs(&m.trade_rs);
        assert_eq!(pooled.n_trades, m.n_trades);
        assert_eq!(pooled.expectancy_r, m.expectancy_r);
        assert_eq!(pooled.win_rate, m.win_rate);
        assert_eq!(pooled.avg_win_r, m.avg_win_r);
        assert_eq!(pooled.avg_loss_r, m.avg_loss_r);
        assert_eq!(pooled.profit_factor, m.profit_factor);
        assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown);
        assert_eq!(pooled.sqn, m.sqn);
        assert_eq!(pooled.sqn_normalized, m.sqn_normalized);
    }

    #[test]
    fn rmetrics_partial_eq_ignores_trade_rs() {
        // two RMetrics equal in every metric but differing in trade_rs compare EQUAL
        // (trade_rs is an in-memory conduit, excluded from equality) — this is what
        // keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped,
        // so it deserializes empty).
        let a = summarize_r(&pm_record_two_closed_trades(), &[]);
        let mut b = a.clone();
        b.trade_rs = Vec::new();
        assert_eq!(a, b);
    }

    #[test]
    fn populated_trade_rs_is_absent_from_serialized_json() {
        // the in-memory conduit must never reach the wire: a populated trade_rs is
        // dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
        let m = summarize_r(&pm_record_two_closed_trades(), &[]);
        assert_eq!(m.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated");
        let json = serde_json::to_string(&m).expect("RMetrics serializes");
        assert!(!json.contains("trade_rs"), "trade_rs must not reach the wire: {json}");
    }

    /// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
    /// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6,
    /// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13.
    fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec<Scalar>)> {
        let row = |closed: bool, r: f64, open: bool| {
            let mut c = vec![Scalar::f64(0.0); 13];
            c[0] = Scalar::bool(closed);
            c[1] = Scalar::f64(r);
            c[6] = Scalar::f64(1.0); // entry
            c[7] = Scalar::f64(0.5); // stop -> latched 0.5
            c[9] = Scalar::f64(0.3); // conviction
            c[11] = Scalar::bool(open);
            c
        };
        vec![
            (Timestamp(1), row(true, 2.0, false)),
            (Timestamp(2), row(true, -1.0, false)),
        ]
    }

    /// A dense PositionManagement record whose last row is still open: one closed
    /// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R
    /// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade).
    /// Same column map / width as `pm_record_two_closed_trades`.
    fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec<Scalar>)> {
        let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| {
            let mut c = vec![Scalar::f64(0.0); 13];
            c[0] = Scalar::bool(closed);
            c[1] = Scalar::f64(realized_r);
            c[6] = Scalar::f64(1.0); // entry
            c[7] = Scalar::f64(0.5); // stop -> latched 0.5
            c[9] = Scalar::f64(0.3); // conviction
            c[11] = Scalar::bool(open);
            c[12] = Scalar::f64(unrealized_r);
            c
        };
        vec![
            (Timestamp(1), row(true, 2.0, false, 0.0)),
            (Timestamp(2), row(false, 0.0, true, 0.5)),
        ]
    }

    #[test]
    fn r_metrics_from_rs_folds_a_flat_series() {
        // pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3,
        // profit_factor = (2+1)/1 = 3. At cost 0 (frictionless) net == gross.
        // conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled
        // concept -> 0.
        let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]);
        assert_eq!(m.n_trades, 3);
        assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12);
        assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12);
        assert!((m.profit_factor - 3.0).abs() < 1e-12);
        assert_eq!(m.net_expectancy_r, m.expectancy_r);
        assert_eq!(m.conviction_terciles_r, [0.0; 3]);
        assert_eq!(m.n_open_at_end, 0);
    }

    #[test]
    fn r_metrics_from_rs_empty_is_all_zero() {
        let m = r_metrics_from_rs(&[]);
        assert_eq!(m.n_trades, 0);
        assert_eq!(m.expectancy_r, 0.0);
        assert_eq!(m.sqn, 0.0);
    }
}
