//! Yes/No parity monitoring and cross-platform arbitrage detection.
//!
//! In prediction markets, YES + NO prices should sum to ~1.0.
//! Deviations indicate mispricing or arbitrage opportunities.
//! All computations are branchless where possible for hot-path performance.

use pyo3::prelude::*;

/// Result of a YES/NO parity check for a single market.
#[pyclass]
#[derive(Debug, Clone)]
pub struct ParityResult {
    #[pyo3(get)]
    pub market_id: String,
    /// Best YES price (ask for buying YES, or feed price).
    #[pyo3(get)]
    pub yes_price: f64,
    /// Best NO price (1.0 - yes_price, or actual NO price if available).
    #[pyo3(get)]
    pub no_price: f64,
    /// Deviation from parity: |yes + no - 1.0|.
    #[pyo3(get)]
    pub deviation: f64,
    /// True if deviation exceeds threshold.
    #[pyo3(get)]
    pub is_violation: bool,
    /// The exchange this data is from.
    #[pyo3(get)]
    pub exchange: String,
}

#[pymethods]
impl ParityResult {
    fn __repr__(&self) -> String {
        format!(
            "ParityResult({} yes={:.4} no={:.4} dev={:.4} violation={})",
            self.market_id, self.yes_price, self.no_price, self.deviation, self.is_violation
        )
    }
}

/// A detected arbitrage opportunity across exchanges.
#[pyclass]
#[derive(Debug, Clone)]
pub struct ArbitrageOpportunity {
    #[pyo3(get)]
    pub market_id: String,
    /// Exchange where you would buy.
    #[pyo3(get)]
    pub buy_exchange: String,
    /// Exchange where you would sell.
    #[pyo3(get)]
    pub sell_exchange: String,
    /// Price to buy at (ask on buy exchange).
    #[pyo3(get)]
    pub buy_price: f64,
    /// Price to sell at (bid on sell exchange).
    #[pyo3(get)]
    pub sell_price: f64,
    /// Raw edge before fees: sell_price - buy_price.
    #[pyo3(get)]
    pub raw_edge: f64,
    /// Net edge after estimated fees.
    #[pyo3(get)]
    pub net_edge: f64,
    /// Maximum executable size (min of available liquidity on both sides).
    #[pyo3(get)]
    pub max_size: f64,
    /// Whether this opportunity is executable (net_edge > 0 and max_size > 0).
    #[pyo3(get)]
    pub is_executable: bool,
}

#[pymethods]
impl ArbitrageOpportunity {
    fn __repr__(&self) -> String {
        format!(
            "ArbitrageOpportunity({} buy@{}={:.4} sell@{}={:.4} edge={:.4} net={:.4} size={:.1})",
            self.market_id,
            self.buy_exchange, self.buy_price,
            self.sell_exchange, self.sell_price,
            self.raw_edge, self.net_edge, self.max_size,
        )
    }
}

/// Check YES/NO parity for a market given two prices.
///
/// `#[inline]` — hot path, called every tick per market.
#[inline]
pub fn check_parity(
    market_id: &str,
    yes_price: f64,
    no_price: f64,
    threshold: f64,
    exchange: &str,
) -> ParityResult {
    if !yes_price.is_finite() || !no_price.is_finite() {
        return ParityResult {
            market_id: market_id.to_string(),
            yes_price,
            no_price,
            deviation: f64::NAN,
            is_violation: false,
            exchange: exchange.to_string(),
        };
    }
    let sum = yes_price + no_price;
    let deviation = (sum - 1.0).abs();
    ParityResult {
        market_id: market_id.to_string(),
        yes_price,
        no_price,
        deviation,
        is_violation: deviation > threshold,
        exchange: exchange.to_string(),
    }
}

/// Detect cross-exchange arbitrage for a single market.
///
/// Compares bid/ask across two exchanges. An arb exists when
/// one exchange's bid > another's ask (you can buy low, sell high).
///
/// `fee_rate_a` and `fee_rate_b`: round-trip fee rate per exchange (e.g., 0.002 for 20bps).
#[inline]
pub fn detect_arbitrage(
    market_id: &str,
    exchange_a: &str,
    bid_a: f64,
    ask_a: f64,
    exchange_b: &str,
    bid_b: f64,
    ask_b: f64,
    fee_rate_a: f64,
    fee_rate_b: f64,
    max_liquidity: f64,
) -> Option<ArbitrageOpportunity> {
    // Direction 1: buy on A (at ask_a), sell on B (at bid_b)
    let edge_ab = bid_b - ask_a;
    // Direction 2: buy on B (at ask_b), sell on A (at bid_a)
    let edge_ba = bid_a - ask_b;

    if edge_ab > edge_ba && edge_ab > 0.0 {
        let total_fees = ask_a * fee_rate_a + bid_b * fee_rate_b;
        let net = edge_ab - total_fees;
        Some(ArbitrageOpportunity {
            market_id: market_id.to_string(),
            buy_exchange: exchange_a.to_string(),
            sell_exchange: exchange_b.to_string(),
            buy_price: ask_a,
            sell_price: bid_b,
            raw_edge: edge_ab,
            net_edge: net,
            max_size: max_liquidity,
            is_executable: net > 0.0 && max_liquidity > 0.0,
        })
    } else if edge_ba > 0.0 {
        let total_fees = ask_b * fee_rate_b + bid_a * fee_rate_a;
        let net = edge_ba - total_fees;
        Some(ArbitrageOpportunity {
            market_id: market_id.to_string(),
            buy_exchange: exchange_b.to_string(),
            sell_exchange: exchange_a.to_string(),
            buy_price: ask_b,
            sell_price: bid_a,
            raw_edge: edge_ba,
            net_edge: net,
            max_size: max_liquidity,
            is_executable: net > 0.0 && max_liquidity > 0.0,
        })
    } else {
        None
    }
}

// ---------------------------------------------------------------------------
// Multi-outcome event parity and arbitrage
// ---------------------------------------------------------------------------

/// A detected arbitrage opportunity within a multi-outcome event.
#[pyclass]
#[derive(Debug, Clone)]
pub struct EventArbitrageOpportunity {
    #[pyo3(get)]
    pub event_id: String,
    #[pyo3(get)]
    pub exchange: String,
    /// Sum of all outcome YES prices.
    #[pyo3(get)]
    pub price_sum: f64,
    /// Overround: sum - 1.0.
    #[pyo3(get)]
    pub overround: f64,
    /// "buy_all" if sum < 1 (outcomes underpriced), "sell_all" if sum > 1.
    #[pyo3(get)]
    pub direction: String,
    #[pyo3(get)]
    pub net_edge: f64,
    #[pyo3(get)]
    pub is_executable: bool,
}

#[pymethods]
impl EventArbitrageOpportunity {
    fn __repr__(&self) -> String {
        format!(
            "EventArbitrageOpportunity(event='{}' sum={:.4} overround={:.4} dir={} edge={:.4})",
            self.event_id, self.price_sum, self.overround, self.direction, self.net_edge,
        )
    }
}

/// Check parity across all outcomes in a multi-outcome event.
/// Sum of YES prices should be ~1.0.
#[inline]
pub fn check_event_parity(
    event_id: &str,
    outcome_prices: &[f64],
    threshold: f64,
    exchange: &str,
) -> ParityResult {
    if outcome_prices.iter().any(|p| !p.is_finite()) {
        return ParityResult {
            market_id: event_id.to_string(),
            yes_price: f64::NAN,
            no_price: f64::NAN,
            deviation: f64::NAN,
            is_violation: false,
            exchange: exchange.to_string(),
        };
    }
    let sum: f64 = outcome_prices.iter().sum();
    let deviation = (sum - 1.0).abs();
    ParityResult {
        market_id: event_id.to_string(),
        yes_price: sum,
        no_price: 1.0 - sum,
        deviation,
        is_violation: deviation > threshold,
        exchange: exchange.to_string(),
    }
}

/// Detect arbitrage in a multi-outcome event.
/// If sum < 1.0 - fees: buy all outcomes = guaranteed profit.
/// If sum > 1.0 + fees: sell all (buy all NOs) = guaranteed profit.
#[inline]
pub fn detect_event_arbitrage(
    event_id: &str,
    outcome_prices: &[(String, f64)],
    fee_rate: f64,
    exchange: &str,
) -> Option<EventArbitrageOpportunity> {
    let sum: f64 = outcome_prices.iter().map(|(_, p)| p).sum();
    let overround = sum - 1.0;
    let n = outcome_prices.len() as f64;
    // Total fees for trading all outcomes (buy or sell each)
    let total_fees = fee_rate * n;

    if overround.abs() <= total_fees {
        // Edge consumed by fees
        return None;
    }

    let (direction, net_edge) = if overround < 0.0 {
        // Sum < 1: buy all outcomes for guaranteed profit
        ("buy_all".to_string(), overround.abs() - total_fees)
    } else {
        // Sum > 1: sell all outcomes (buy all NOs) for guaranteed profit
        ("sell_all".to_string(), overround - total_fees)
    };

    if net_edge <= 0.0 {
        return None;
    }

    Some(EventArbitrageOpportunity {
        event_id: event_id.to_string(),
        exchange: exchange.to_string(),
        price_sum: sum,
        overround,
        direction,
        net_edge,
        is_executable: true,
    })
}

// ---------------------------------------------------------------------------
// Parity arbitrage (same-exchange YES + NO < $1.00)
// ---------------------------------------------------------------------------

/// A detected parity arbitrage opportunity on a single exchange.
/// YES_ask + NO_ask < 1.0 means buying both locks in guaranteed profit.
#[pyclass]
#[derive(Debug, Clone)]
pub struct ParityArbitrageOpportunity {
    #[pyo3(get)]
    pub market_id: String,
    #[pyo3(get)]
    pub exchange: String,
    #[pyo3(get)]
    pub yes_ask: f64,
    #[pyo3(get)]
    pub no_ask: f64,
    #[pyo3(get)]
    pub total_cost: f64,
    #[pyo3(get)]
    pub raw_edge: f64,
    #[pyo3(get)]
    pub net_edge: f64,
    #[pyo3(get)]
    pub max_size: f64,
    #[pyo3(get)]
    pub is_executable: bool,
}

#[pymethods]
impl ParityArbitrageOpportunity {
    fn __repr__(&self) -> String {
        format!(
            "ParityArbitrageOpportunity({} yes_ask={:.4} no_ask={:.4} cost={:.4} edge={:.4})",
            self.market_id, self.yes_ask, self.no_ask, self.total_cost, self.net_edge,
        )
    }
}

/// Detect parity arbitrage on a single exchange.
///
/// If YES_ask + NO_ask < 1.0 (after fees), buying both sides locks in a profit.
/// `no_ask` can be derived from the book (1.0 - yes_bid when there's a bid side).
#[inline]
pub fn detect_parity_arbitrage(
    market_id: &str,
    yes_ask: f64,
    no_ask: f64,
    fee_rate: f64,
    max_liquidity: f64,
    exchange: &str,
) -> Option<ParityArbitrageOpportunity> {
    if !yes_ask.is_finite() || !no_ask.is_finite() || yes_ask <= 0.0 || no_ask <= 0.0 {
        return None;
    }
    let total_cost = yes_ask + no_ask;
    let raw_edge = 1.0 - total_cost;
    // Two trades: buy YES + buy NO, each incurs fee
    let net_edge = raw_edge - fee_rate * 2.0;
    if net_edge <= 0.0 {
        return None;
    }
    Some(ParityArbitrageOpportunity {
        market_id: market_id.to_string(),
        exchange: exchange.to_string(),
        yes_ask,
        no_ask,
        total_cost,
        raw_edge,
        net_edge,
        max_size: max_liquidity,
        is_executable: net_edge > 0.0 && max_liquidity > 0.0,
    })
}

// ---------------------------------------------------------------------------
// Latency arbitrage (fast feed detects move before book updates)
// ---------------------------------------------------------------------------

/// A detected latency arbitrage opportunity.
#[pyclass]
#[derive(Debug, Clone)]
pub struct LatencyArbOpportunity {
    /// "buy" or "sell"
    #[pyo3(get)]
    pub direction: String,
    #[pyo3(get)]
    pub expected_price: f64,
    #[pyo3(get)]
    pub market_price: f64,
    #[pyo3(get)]
    pub edge: f64,
    #[pyo3(get)]
    pub staleness_ms: f64,
    #[pyo3(get)]
    pub ref_price_change: f64,
}

#[pymethods]
impl LatencyArbOpportunity {
    fn __repr__(&self) -> String {
        format!(
            "LatencyArbOpportunity(dir={} expected={:.4} market={:.4} edge={:.4} stale={:.0}ms)",
            self.direction, self.expected_price, self.market_price, self.edge, self.staleness_ms,
        )
    }
}

/// Detect latency arbitrage.
///
/// When a fast reference feed (e.g., Binance) shows a price move but the
/// prediction market book hasn't updated yet (stale), the expected repricing
/// creates an edge.
#[inline]
pub fn detect_latency_arb(
    ref_price_current: f64,
    ref_price_previous: f64,
    market_bid: f64,
    market_ask: f64,
    market_last_update_age_ms: f64,
    sensitivity: f64,
    min_age_ms: f64,
    min_edge: f64,
    fee_rate: f64,
) -> Option<LatencyArbOpportunity> {
    if !ref_price_current.is_finite()
        || !ref_price_previous.is_finite()
        || !market_bid.is_finite()
        || !market_ask.is_finite()
    {
        return None;
    }

    // Book must be stale
    if market_last_update_age_ms < min_age_ms {
        return None;
    }

    let ref_change = ref_price_current - ref_price_previous;
    let expected_move = ref_change * sensitivity;

    // Use mid as baseline for expected price
    let mid = (market_bid + market_ask) / 2.0;
    let expected_price = (mid + expected_move).clamp(0.01, 0.99);

    // If expected price > ask → buy signal (market will reprice up)
    if expected_price > market_ask + fee_rate + min_edge {
        let edge = expected_price - market_ask - fee_rate;
        return Some(LatencyArbOpportunity {
            direction: "buy".to_string(),
            expected_price,
            market_price: market_ask,
            edge,
            staleness_ms: market_last_update_age_ms,
            ref_price_change: ref_change,
        });
    }

    // If expected price < bid → sell signal (market will reprice down)
    if expected_price < market_bid - fee_rate - min_edge {
        let edge = market_bid - expected_price - fee_rate;
        return Some(LatencyArbOpportunity {
            direction: "sell".to_string(),
            expected_price,
            market_price: market_bid,
            edge,
            staleness_ms: market_last_update_age_ms,
            ref_price_change: ref_change,
        });
    }

    None
}

// ---------------------------------------------------------------------------
// Statistical arbitrage primitives
// ---------------------------------------------------------------------------

/// OLS cointegration test for two price series.
///
/// Returns (hedge_ratio, residuals, adf_statistic).
/// hedge_ratio: OLS beta (cov(a,b)/var(b)).
/// residuals: a - beta*b.
/// adf_statistic: ADF test statistic on residuals (more negative = more stationary).
#[pyfunction]
pub fn cointegration_test(prices_a: Vec<f64>, prices_b: Vec<f64>) -> PyResult<(f64, Vec<f64>, f64)> {
    let n = prices_a.len();
    if n != prices_b.len() {
        return Err(pyo3::exceptions::PyValueError::new_err(
            "prices_a and prices_b must have the same length",
        ));
    }
    if n < 5 {
        return Err(pyo3::exceptions::PyValueError::new_err(
            "need at least 5 observations for cointegration test",
        ));
    }
    if prices_a.iter().any(|x| !x.is_finite()) || prices_b.iter().any(|x| !x.is_finite()) {
        return Err(pyo3::exceptions::PyValueError::new_err(
            "price series contain non-finite values",
        ));
    }

    let nf = n as f64;
    let mean_a = prices_a.iter().sum::<f64>() / nf;
    let mean_b = prices_b.iter().sum::<f64>() / nf;

    let mut cov_ab = 0.0_f64;
    let mut var_b = 0.0_f64;
    for i in 0..n {
        let da = prices_a[i] - mean_a;
        let db = prices_b[i] - mean_b;
        cov_ab += da * db;
        var_b += db * db;
    }

    if var_b < 1e-30 {
        return Err(pyo3::exceptions::PyValueError::new_err(
            "prices_b has zero variance",
        ));
    }

    let hedge_ratio = cov_ab / var_b;

    // Residuals: a - beta * b
    let residuals: Vec<f64> = prices_a
        .iter()
        .zip(prices_b.iter())
        .map(|(a, b)| a - hedge_ratio * b)
        .collect();

    // ADF statistic on residuals (inline to avoid auth gate on fracdiff::adf_statistic)
    let adf = compute_adf(&residuals);

    Ok((hedge_ratio, residuals, adf))
}

/// Compute ADF statistic for a series (internal, no auth gate).
fn compute_adf(series: &[f64]) -> f64 {
    let n = series.len();
    if n < 3 {
        return 0.0;
    }
    let m = n - 1;
    let mf = m as f64;

    let mut delta_y = Vec::with_capacity(m);
    let mut y_lag = Vec::with_capacity(m);
    for t in 1..n {
        delta_y.push(series[t] - series[t - 1]);
        y_lag.push(series[t - 1]);
    }

    let mean_dy = delta_y.iter().sum::<f64>() / mf;
    let mean_yl = y_lag.iter().sum::<f64>() / mf;

    let mut ss_xy = 0.0_f64;
    let mut ss_xx = 0.0_f64;
    for i in 0..m {
        let dx = y_lag[i] - mean_yl;
        let dy = delta_y[i] - mean_dy;
        ss_xy += dx * dy;
        ss_xx += dx * dx;
    }

    if ss_xx < 1e-30 {
        return -f64::INFINITY;
    }

    let beta = ss_xy / ss_xx;
    let alpha = mean_dy - beta * mean_yl;

    let mut sse = 0.0_f64;
    for i in 0..m {
        let predicted = alpha + beta * y_lag[i];
        let residual = delta_y[i] - predicted;
        sse += residual * residual;
    }

    let sigma2 = sse / (mf - 2.0).max(1.0);
    let se_beta = (sigma2 / ss_xx).sqrt();
    if se_beta < 1e-30 {
        return -f64::INFINITY;
    }

    beta / se_beta
}

/// Compute z-score of the latest residual given a lookback window.
///
/// Uses the last `lookback` values of `residuals` to compute rolling mean/std,
/// then z-scores the final value.
#[pyfunction]
pub fn spread_zscore(residuals: Vec<f64>, lookback: usize) -> f64 {
    if residuals.is_empty() || lookback == 0 {
        return 0.0;
    }
    let n = residuals.len();
    let start = if n > lookback { n - lookback } else { 0 };
    let window = &residuals[start..];
    let wn = window.len() as f64;
    if wn < 2.0 {
        return 0.0;
    }

    let mean = window.iter().sum::<f64>() / wn;
    let var = window.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / (wn - 1.0);
    let std = var.sqrt();
    if std < 1e-12 {
        return 0.0;
    }
    (residuals[n - 1] - mean) / std
}

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

    #[test]
    fn parity_no_violation() {
        let r = check_parity("mkt_1", 0.55, 0.45, 0.02, "paper");
        assert!((r.deviation).abs() < 1e-10);
        assert!(!r.is_violation);
    }

    #[test]
    fn parity_violation() {
        let r = check_parity("mkt_1", 0.55, 0.50, 0.02, "paper");
        assert!((r.deviation - 0.05).abs() < 1e-10);
        assert!(r.is_violation);
    }

    #[test]
    fn arb_buy_a_sell_b() {
        let opp = detect_arbitrage(
            "mkt_1", "poly", 0.50, 0.52, "kalshi", 0.56, 0.58, 0.002, 0.002, 100.0,
        );
        let opp = opp.unwrap();
        assert_eq!(opp.buy_exchange, "poly");
        assert_eq!(opp.sell_exchange, "kalshi");
        assert!((opp.raw_edge - 0.04).abs() < 1e-10); // 0.56 - 0.52
        assert!(opp.is_executable);
    }

    #[test]
    fn arb_buy_b_sell_a() {
        let opp = detect_arbitrage(
            "mkt_1", "poly", 0.58, 0.60, "kalshi", 0.50, 0.52, 0.002, 0.002, 100.0,
        );
        let opp = opp.unwrap();
        assert_eq!(opp.buy_exchange, "kalshi");
        assert_eq!(opp.sell_exchange, "poly");
    }

    #[test]
    fn no_arb_when_spread_overlaps() {
        let opp = detect_arbitrage(
            "mkt_1", "poly", 0.50, 0.55, "kalshi", 0.50, 0.55, 0.002, 0.002, 100.0,
        );
        assert!(opp.is_none());
    }

    #[test]
    fn arb_not_executable_after_fees() {
        // Tiny edge eaten by fees
        let opp = detect_arbitrage(
            "mkt_1", "poly", 0.50, 0.500, "kalshi", 0.501, 0.55, 0.01, 0.01, 100.0,
        );
        if let Some(opp) = opp {
            assert!(!opp.is_executable);
        }
    }

    // Event parity tests

    #[test]
    fn event_parity_balanced() {
        let r = check_event_parity(
            "election",
            &[0.25, 0.25, 0.25, 0.25],
            0.02,
            "polymarket",
        );
        assert!((r.deviation).abs() < 1e-10);
        assert!(!r.is_violation);
    }

    #[test]
    fn event_parity_violation() {
        let r = check_event_parity(
            "election",
            &[0.30, 0.30, 0.25, 0.20],
            0.02,
            "polymarket",
        );
        assert!((r.deviation - 0.05).abs() < 1e-10);
        assert!(r.is_violation);
    }

    #[test]
    fn event_arb_buy_all() {
        // Sum = 0.90, underpriced
        let opp = detect_event_arbitrage(
            "election",
            &[
                ("Trump".into(), 0.30),
                ("Biden".into(), 0.25),
                ("DeSantis".into(), 0.20),
                ("Other".into(), 0.15),
            ],
            0.002,
            "polymarket",
        );
        let opp = opp.unwrap();
        assert_eq!(opp.direction, "buy_all");
        assert!(opp.net_edge > 0.0);
        assert!(opp.is_executable);
    }

    #[test]
    fn event_arb_sell_all() {
        // Sum = 1.10, overpriced
        let opp = detect_event_arbitrage(
            "election",
            &[
                ("Trump".into(), 0.40),
                ("Biden".into(), 0.35),
                ("DeSantis".into(), 0.20),
                ("Other".into(), 0.15),
            ],
            0.002,
            "polymarket",
        );
        let opp = opp.unwrap();
        assert_eq!(opp.direction, "sell_all");
        assert!(opp.net_edge > 0.0);
    }

    #[test]
    fn event_arb_fees_consume_edge() {
        // Sum = 1.01, tiny overround eaten by fees (4 outcomes * 0.01 fee = 0.04)
        let opp = detect_event_arbitrage(
            "election",
            &[
                ("A".into(), 0.26),
                ("B".into(), 0.25),
                ("C".into(), 0.25),
                ("D".into(), 0.25),
            ],
            0.01,
            "polymarket",
        );
        assert!(opp.is_none());
    }

    // Parity arbitrage tests

    #[test]
    fn parity_arb_found() {
        let opp = detect_parity_arbitrage("mkt_1", 0.45, 0.48, 0.002, 100.0, "paper");
        let opp = opp.unwrap();
        assert!((opp.total_cost - 0.93).abs() < 1e-10);
        assert!((opp.raw_edge - 0.07).abs() < 1e-10);
        assert!(opp.net_edge > 0.0);
        assert!(opp.is_executable);
    }

    #[test]
    fn parity_arb_no_edge() {
        // YES 0.50 + NO 0.51 = 1.01 > 1.0, no arb
        let opp = detect_parity_arbitrage("mkt_1", 0.50, 0.51, 0.002, 100.0, "paper");
        assert!(opp.is_none());
    }

    #[test]
    fn parity_arb_fees_consume() {
        // YES 0.495 + NO 0.500 = 0.995, raw_edge = 0.005, fees = 0.004, net = 0.001
        let opp = detect_parity_arbitrage("mkt_1", 0.495, 0.500, 0.005, 100.0, "paper");
        // net_edge = 0.005 - 0.010 = -0.005, should be None
        assert!(opp.is_none());
    }

    #[test]
    fn parity_arb_nan_input() {
        let opp = detect_parity_arbitrage("mkt_1", f64::NAN, 0.48, 0.002, 100.0, "paper");
        assert!(opp.is_none());
    }

    // Latency arb tests

    #[test]
    fn latency_arb_buy_signal() {
        let opp = detect_latency_arb(
            0.60, 0.50,   // ref moved up 10c
            0.48, 0.52,   // market still at 50c mid
            1000.0,       // 1 second stale
            1.0,          // 1:1 sensitivity
            500.0,        // min 500ms staleness
            0.02,         // min edge
            0.002,        // fee
        );
        let opp = opp.unwrap();
        assert_eq!(opp.direction, "buy");
        assert!(opp.edge > 0.0);
    }

    #[test]
    fn latency_arb_sell_signal() {
        let opp = detect_latency_arb(
            0.40, 0.50,   // ref moved down 10c
            0.48, 0.52,   // market still at 50c mid
            1000.0,
            1.0, 500.0, 0.02, 0.002,
        );
        let opp = opp.unwrap();
        assert_eq!(opp.direction, "sell");
        assert!(opp.edge > 0.0);
    }

    #[test]
    fn latency_arb_not_stale_enough() {
        let opp = detect_latency_arb(
            0.60, 0.50, 0.48, 0.52,
            100.0,  // only 100ms stale
            1.0, 500.0, 0.02, 0.002,
        );
        assert!(opp.is_none());
    }

    #[test]
    fn latency_arb_no_edge() {
        let opp = detect_latency_arb(
            0.501, 0.500,  // tiny ref move
            0.48, 0.52,
            1000.0, 1.0, 500.0, 0.02, 0.002,
        );
        assert!(opp.is_none());
    }

    // Cointegration + spread_zscore tests

    #[test]
    fn cointegration_basic() {
        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
        let b = vec![1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1, 9.1, 10.1];
        let (ratio, residuals, adf) = cointegration_test(a, b).unwrap();
        // Near perfect cointegration, ratio ≈ 1.0
        assert!((ratio - 1.0).abs() < 0.1);
        assert_eq!(residuals.len(), 10);
        // ADF should be strongly negative (stationary residuals)
        assert!(adf.is_finite());
    }

    #[test]
    fn cointegration_unequal_lengths() {
        let a = vec![1.0, 2.0, 3.0];
        let b = vec![1.0, 2.0];
        assert!(cointegration_test(a, b).is_err());
    }

    #[test]
    fn spread_zscore_basic() {
        let residuals = vec![0.0, 0.0, 0.0, 0.0, 10.0];
        let z = spread_zscore(residuals, 5);
        assert!(z > 1.0); // Last value is far above mean
    }

    #[test]
    fn spread_zscore_empty() {
        assert_eq!(spread_zscore(vec![], 10), 0.0);
    }

    #[test]
    fn spread_zscore_constant() {
        let residuals = vec![5.0, 5.0, 5.0, 5.0, 5.0];
        assert_eq!(spread_zscore(residuals, 5), 0.0);
    }
}
