use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;

use arrow::array::{
    Array, ArrayRef, BooleanArray, Float64Array, TimestampNanosecondArray, UInt32Array, UInt8Array,
};
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use arrow::record_batch::RecordBatch;
use bt_analytics::{
    compute_benchmark_daily_returns, compute_capm_alpha, compute_daily_returns,
    compute_performance_metrics_with_rf, compute_trade_statistics, resample_to_daily,
    PerformanceMetrics,
};
use bt_data::DataStore;
use bt_expr::{
    cross_sectional_mean_multi, cross_sectional_rank_multi, extract_cross_section_info,
    extract_symbol_ref_info, has_cross_sectional_ops, has_symbol_ref_ops, CompiledExpr,
    CompiledNode, CompiledOp, CrossSectionKind, DefaultExprEvaluator, Expr, ExprEvaluator,
    IndicatorCache, ScalarValue,
};
use bt_kernel::{BtError, Result, SymbolId, TimeRange};
use rayon::prelude::*;
use rayon::ThreadPoolBuilder;
use serde::{Deserialize, Serialize};

use crate::alignment::{align_symbol_bars, compute_resample_groups, resample_batch, AlignedBars};
use crate::fees::{borrow_cost, compute_fees, funding_pnl, FillType};
use crate::fill::{
    approximate_intra_bar_price, bar_data, resolve_execution_price, validate_fill_price, BarsView,
};
use crate::manifest::{build_run_manifest, RunManifest};
use crate::orders::{ActiveBracket, ExitReason, OrderConfig, TimeInForce};
use crate::slippage::{Side, SlippageConfig};
use crate::sweep::{expand_param_grid, strategy_with_params, ParamGrid};
use crate::universe::validate_universe;

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct StrategyMetadata {
    pub description: Option<String>,
}

#[derive(Clone, Debug)]
pub struct StrategyPlan {
    pub name: String,
    pub signals: HashMap<String, CompiledExpr>,
    pub position_sizing: CompiledExpr,
    pub required_columns: Vec<String>,
    pub required_features: Vec<String>,
    pub parameters: HashMap<String, ScalarValue>,
    pub metadata: StrategyMetadata,
    /// Raw expressions for re-compilation when dynamic periods (DynPeriod::Param) are used.
    /// None when all periods are fixed (the common case).
    pub raw_signals: Option<HashMap<String, Expr>>,
    pub raw_position_sizing: Option<Expr>,
    pub has_dynamic_periods: bool,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum BarInterval {
    Seconds(u32),
    Minutes(u32),
    Hours(u32),
    Days(u32),
}

impl Default for BarInterval {
    fn default() -> Self {
        Self::Seconds(1)
    }
}

impl BarInterval {
    pub fn as_seconds(&self) -> f64 {
        match self {
            Self::Seconds(value) => *value as f64,
            Self::Minutes(value) => (*value as f64) * 60.0,
            Self::Hours(value) => (*value as f64) * 3_600.0,
            Self::Days(value) => (*value as f64) * 86_400.0,
        }
    }

    pub fn as_nanos(&self) -> i64 {
        (self.as_seconds() * 1_000_000_000.0) as i64
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BacktestConfig {
    pub universe: Vec<SymbolId>,
    pub time_range: TimeRange,
    /// Signal evaluation timeframe. Indicators and strategy expressions are
    /// computed on bars aggregated to this interval. When coarser than native
    /// data, hybrid mode auto-activates: signals on coarse bars, simulation
    /// on native fine bars for accurate drawdowns.
    pub bar_interval: BarInterval,
    pub initial_capital: f64,
    pub currency: String,
    pub execution: ExecutionConfig,
    pub fees: FeeConfig,
    pub slippage: SlippageConfig,
    pub data_version: Option<String>,
    pub rng_seed: Option<u64>,
    #[serde(
        default = "default_trading_days_per_year",
        skip_serializing_if = "is_default_trading_days"
    )]
    pub trading_days_per_year: f64,
    /// Annual risk-free rate for Sharpe/Sortino calculation (default: 0.025 = 2.5%).
    #[serde(default = "default_risk_free_rate")]
    pub risk_free_rate: f64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_resolution: Option<BarInterval>,
    /// Override for signal evaluation resolution. When set, signals are
    /// evaluated on bars resampled to this interval instead of `bar_interval`.
    /// When `None` (default), `bar_interval` determines the signal timeframe.
    /// Simulation always runs on native-resolution bars regardless.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resample_to: Option<BarInterval>,
    /// Feature sets to preload before signal evaluation.
    /// Feature columns are loaded from `features/{set_name}/` partitions and
    /// injected into each symbol's expression environment so signals can
    /// reference them via `col("feature_name")`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub feature_sets: Vec<String>,
    /// Mapping from human-readable symbol names (e.g. "BTCUSDT") to `SymbolId`.
    /// Used by `SymbolRef` expressions to resolve cross-asset references.
    /// If empty, falls back to using `SymbolId.0.to_string()` as the name.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub symbol_names: HashMap<String, SymbolId>,
    /// Number of bars to skip at the start before acting on signals.
    /// Allows indicators (EMA, SMA, etc.) to stabilise. During warmup,
    /// equity tracking still runs but no trades are generated.
    #[serde(default)]
    pub warmup_bars: u32,
}

impl Default for BacktestConfig {
    fn default() -> Self {
        Self {
            universe: vec![SymbolId(1)],
            time_range: TimeRange {
                start: bt_kernel::Timestamp::from_nanos(0),
                end: bt_kernel::Timestamp::from_nanos(4_000_000_000),
            },
            bar_interval: BarInterval::default(),
            initial_capital: 1000.0,
            currency: "USD".to_string(),
            execution: ExecutionConfig::default(),
            fees: FeeConfig::default(),
            slippage: SlippageConfig::default(),
            data_version: None,
            rng_seed: None,
            trading_days_per_year: 365.25,
            risk_free_rate: 0.025,
            output_resolution: None,
            resample_to: None,
            feature_sets: Vec::new(),
            symbol_names: HashMap::new(),
            warmup_bars: 0,
        }
    }
}

fn default_trading_days_per_year() -> f64 {
    365.25
}

fn is_default_trading_days(v: &f64) -> bool {
    (*v - 365.25).abs() < f64::EPSILON
}

fn default_risk_free_rate() -> f64 {
    0.025
}

fn is_default_fill_model(v: &FillModel) -> bool {
    *v == FillModel::default()
}

fn is_default_fill_type(v: &FillType) -> bool {
    *v == FillType::default()
}

/// How position_sizing output is interpreted.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum PositionSizingMode {
    /// Target is a fraction of equity (e.g. 1.0 = 100% of equity allocated).
    /// Converted to units via: `target_units = fraction * equity / close`.
    #[default]
    FractionOfEquity,
    /// Target is an absolute number of units (shares/contracts/coins).
    Units,
    /// Like `FractionOfEquity` but uses **initial capital** instead of current
    /// equity.  This disables compounding: position size stays constant
    /// regardless of P&L.
    /// Converted to units via: `target_units = fraction * initial_capital / close`.
    FractionOfInitialCapital,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExecutionConfig {
    pub signal_delay: u32,
    pub execution_price: ExecutionPrice,
    pub max_position_pct: f64,
    pub allow_short: bool,
    pub allow_fractional: bool,
    pub skip_gap_bars: bool,
    #[serde(default)]
    pub position_sizing_mode: PositionSizingMode,
    /// When `true`, the signal is treated as a **delta** to add to the current
    /// position each bar (pyramiding), instead of a target position.
    /// Works with any `PositionSizingMode`:
    /// - `FractionOfEquity + pyramiding` → add `signal * equity / close` units
    /// - `FractionOfInitialCapital + pyramiding` → add `signal * initial_capital / close` units
    /// - `Units + pyramiding` → add `signal` units
    ///
    /// Signal semantics: `0.0` = go flat, `NaN`/null = hold, nonzero = add.
    #[serde(default)]
    pub pyramiding: bool,
    #[serde(default, skip_serializing_if = "is_default_fill_model")]
    pub fill_model: FillModel,
    /// Order management: limit entries, stop-loss, take-profit, trailing stops.
    /// When `None` / default, the engine uses the legacy market-order path.
    #[serde(default, skip_serializing_if = "OrderConfig::is_empty")]
    pub orders: OrderConfig,
}

impl Default for ExecutionConfig {
    fn default() -> Self {
        Self {
            signal_delay: 1,
            execution_price: ExecutionPrice::AtClose,
            max_position_pct: 1.0,
            allow_short: true,
            allow_fractional: true,
            skip_gap_bars: false,
            position_sizing_mode: PositionSizingMode::FractionOfEquity,
            pyramiding: false,
            fill_model: FillModel::default(),
            orders: OrderConfig::default(),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ExecutionPrice {
    /// Fill at the close price of the execution bar.
    #[serde(alias = "NextBarClose")]
    AtClose,
    /// Fill at the open price of the execution bar.
    #[serde(alias = "NextBarOpen")]
    AtOpen,
    /// Fill at the VWAP of the execution bar.
    #[serde(alias = "NextBarVwap")]
    AtVwap,
    /// Fill at the mid-price (bid+ask)/2 of the execution bar.
    MidPrice,
    /// Fill at a custom column value from the bar data.
    Custom(String),
}

/// How the fill price is approximated within a bar.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum IntraBarPrice {
    /// Use the single point selected by `ExecutionPrice` (legacy behavior).
    #[default]
    SinglePoint,
    /// Typical price: (high + low + close) / 3.
    TypicalPrice,
    /// OHLC average: (open + high + low + close) / 4.
    OhlcAverage,
}

/// Controls how orders are filled against bar data.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FillModel {
    /// Maximum fraction of bar volume an order may consume per bar.
    /// `0.0` means unlimited (fill everything atomically — legacy behavior).
    pub max_participation_rate: f64,
    /// How to approximate the fill price within a bar.
    #[serde(default)]
    pub intra_bar_price: IntraBarPrice,
}

impl Default for FillModel {
    fn default() -> Self {
        Self {
            max_participation_rate: 0.0,
            intra_bar_price: IntraBarPrice::SinglePoint,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FeeConfig {
    pub maker_fee_bps: f64,
    pub taker_fee_bps: f64,
    pub funding_rate_column: Option<String>,
    pub borrow_rate_annual_bps: f64,
    pub min_fee: f64,
    /// Default fill type for fee calculation. Taker is conservative (higher fees).
    #[serde(default, skip_serializing_if = "is_default_fill_type")]
    pub default_fill_type: FillType,
}

impl Default for FeeConfig {
    fn default() -> Self {
        Self {
            maker_fee_bps: 0.0,
            taker_fee_bps: 0.0,
            funding_rate_column: None,
            borrow_rate_annual_bps: 0.0,
            min_fee: 0.0,
            default_fill_type: FillType::default(),
        }
    }
}

/// Per-run timing breakdown (zero overhead — reuses existing Instant measurements).
/// All values in **microseconds** (us) for sub-ms precision.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProfileData {
    /// Phase 1: loading bars from data store (us).
    pub data_load_us: u64,
    /// Phase 2: aligning timestamps across symbols (us).
    pub align_us: u64,
    /// Phase 3: evaluating signal expressions (us).
    pub signal_eval_us: u64,
    /// Phase 4: preparing per-symbol runtime (us).
    pub runtime_prep_us: u64,
    /// Phase 5: simulation loop (us).
    pub simulation_us: u64,
    /// Phase 6: building output (metrics, Arrow batches, manifest) (us).
    pub output_build_us: u64,
    /// Wall-clock total (us).
    pub total_us: u64,
}

#[derive(Clone, Debug)]
pub struct BacktestResult {
    pub manifest: RunManifest,
    pub equity_curve: ArrayRef,
    pub positions: RecordBatch,
    pub trades: RecordBatch,
    pub daily_returns: ArrayRef,
    pub metrics: PerformanceMetrics,
    pub warnings: Vec<String>,
    pub profile: ProfileData,
}

/// Lightweight result for batch/sweep runs — metrics only, no Arrow output.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BatchResultLite {
    pub strategy_name: String,
    pub metrics: PerformanceMetrics,
    pub final_equity: f64,
    pub trade_count: usize,
    #[serde(default)]
    pub profile: ProfileData,
}

/// Tracks in-progress order execution (supports partial fills across bars).
#[derive(Clone, Debug)]
struct PendingOrder {
    signal_row: usize,
    /// Remaining delta to fill (signed: positive = buy, negative = sell).
    remaining_delta: f64,
    /// Weighted sum of fill_price * fill_qty for avg price calculation.
    fill_price_x_qty: f64,
    /// Total quantity already filled.
    total_filled: f64,
    /// Total fees accumulated across partial fills.
    total_fees: f64,
    /// Total slippage accumulated across partial fills.
    total_slippage: f64,
    /// Limit price for limit entry orders (`None` = market order, legacy path).
    limit_price: Option<f64>,
    /// Number of bars this order has been alive (for GTB expiry).
    bars_alive: u32,
    /// Time-in-force policy.
    time_in_force: TimeInForce,
    /// Original side of the order (true = buy, false = sell).
    is_buy: bool,
}

pub trait BacktestRunner: Send + Sync {
    fn run(
        &self,
        strategy: &StrategyPlan,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
    ) -> Result<BacktestResult>;

    fn run_sweep(
        &self,
        strategy: &StrategyPlan,
        param_grid: &ParamGrid,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BacktestResult>>;

    fn run_batch(
        &self,
        strategies: &[StrategyPlan],
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BacktestResult>>;

    fn run_sweep_lite(
        &self,
        strategy: &StrategyPlan,
        param_grid: &ParamGrid,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BatchResultLite>>;

    fn run_batch_lite(
        &self,
        strategies: &[StrategyPlan],
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BatchResultLite>>;
}

pub struct SimpleBacktestRunner {
    evaluator: Box<dyn ExprEvaluator>,
}

impl Default for SimpleBacktestRunner {
    fn default() -> Self {
        Self::new()
    }
}

impl SimpleBacktestRunner {
    pub fn new() -> Self {
        Self {
            evaluator: Box::new(DefaultExprEvaluator::new()),
        }
    }

    pub fn with_evaluator(evaluator: Box<dyn ExprEvaluator>) -> Self {
        Self { evaluator }
    }
}

impl SimpleBacktestRunner {
    /// Load bars from store and align timestamps (phases 1-2).
    /// Returns aligned bars, sorted universe, data version string,
    /// and phase timings (data_load_us, align_us).
    pub fn load_and_align(
        config: &BacktestConfig,
        data_store: &dyn DataStore,
    ) -> Result<(AlignedBars, Vec<SymbolId>, String, u64, u64)> {
        validate_universe(&config.universe)?;

        let t_phase = std::time::Instant::now();
        let raw_bars: HashMap<SymbolId, RecordBatch> = config
            .universe
            .par_iter()
            .map(|&symbol| {
                let bars = data_store.load_bars(
                    symbol,
                    config.time_range,
                    config.data_version.as_deref(),
                )?;
                if bars.num_rows() == 0 {
                    return Err(BtError::Data(format!(
                        "backtest received empty bar dataset for symbol {:?}",
                        symbol
                    )));
                }
                Ok((symbol, bars))
            })
            .collect::<Result<_>>()?;
        let data_load_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = data_load_us / 1000,
            symbols = config.universe.len(),
            "phase 1/6: bars loaded from store"
        );

        let t_phase = std::time::Instant::now();
        let aligned = align_symbol_bars(raw_bars)?;
        let align_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = align_us / 1000,
            rows = aligned.master_timestamps.len(),
            "phase 2/6: timestamps aligned"
        );

        if aligned.master_timestamps.is_empty() {
            return Err(BtError::Data(
                "backtest received empty bar dataset".to_string(),
            ));
        }

        let mut universe_sorted = config.universe.clone();
        universe_sorted.sort_by_key(|s| s.0);

        let version = if let Some(version) = config.data_version.clone() {
            version
        } else {
            data_store.active_version("bars_1m")?.id
        };

        Ok((aligned, universe_sorted, version, data_load_us, align_us))
    }

    /// Run phases 3-6 on pre-loaded aligned bars.
    pub fn run_on_aligned(
        &self,
        strategy: &StrategyPlan,
        config: &BacktestConfig,
        aligned: &AlignedBars,
        universe_sorted: &[SymbolId],
        data_version: &str,
    ) -> Result<BacktestResult> {
        let mut profile = ProfileData::default();
        let master_timestamps = &aligned.master_timestamps;
        let num_bars = master_timestamps.len();

        // --- Hybrid resampling setup ---
        // Signals are evaluated on `bar_interval` resolution (or `resample_to` override).
        // Simulation always runs on native (fine) bars for accurate drawdowns.
        let native_ns = detect_native_interval_ns(master_timestamps);
        let signal_interval = config.resample_to.as_ref().unwrap_or(&config.bar_interval);
        let signal_ns = signal_interval.as_nanos();

        let (coarse_bars, resample_groups) = if signal_ns > native_ns {
            let groups = compute_resample_groups(master_timestamps, signal_ns);
            let coarse_len = groups.len();
            let mut coarse = HashMap::new();
            for (&symbol, batch) in &aligned.symbol_bars {
                coarse.insert(symbol, resample_batch(batch, &groups, coarse_len)?);
            }
            tracing::info!(
                fine_bars = num_bars,
                coarse_bars = coarse_len,
                signal_interval_s = signal_ns / 1_000_000_000,
                native_interval_s = native_ns / 1_000_000_000,
                "hybrid mode: signals on coarse bars, simulation on fine bars"
            );
            (Some(coarse), Some(groups))
        } else {
            (None, None)
        };

        // --- 3. Evaluate signals and position sizing (2-pass if cross-sectional) ---
        let t_phase = std::time::Instant::now();
        let mut warnings = Vec::new();

        let signal_order = topological_signal_order(&strategy.signals)?;
        let signal_levels = topological_signal_levels(&strategy.signals)?;

        // Partition signals into temporal (pass 1), cross-sectional (pass 2),
        // symbol-ref pure (pass 2b), and symbol-ref composite (pass 3).
        //
        // A "pure" symbol-ref signal is just `SymbolRef(sym, Column(col))` — it
        // copies a single column from another symbol's env.
        //
        // A "composite" symbol-ref signal contains SymbolRef embedded in a larger
        // expression (e.g. `RSI(close / SymbolRef(...))`).  These must be
        // evaluated as temporal signals *after* the pure symbol-refs are injected.
        let mut temporal_signals = Vec::new();
        let mut cross_section_signals = Vec::new();
        let mut symbol_ref_signals = Vec::new();
        let mut symbol_ref_composite_signals = Vec::new();
        for name in &signal_order {
            let compiled = strategy.signals.get(name.as_str()).ok_or_else(|| {
                BtError::Strategy(format!("missing compiled signal for '{name}'"))
            })?;
            if has_cross_sectional_ops(compiled) {
                cross_section_signals.push(name.clone());
            } else if has_symbol_ref_ops(compiled) {
                match extract_symbol_ref_info(compiled) {
                    Ok(Some(_)) => symbol_ref_signals.push(name.clone()),
                    _ => symbol_ref_composite_signals.push(name.clone()),
                }
            } else {
                temporal_signals.push(name.clone());
            }
        }

        // Build temporal signal levels (preserving only temporal signals).
        let temporal_set: HashSet<&str> = temporal_signals.iter().map(|s| s.as_str()).collect();
        let temporal_levels: Vec<Vec<String>> = signal_levels
            .iter()
            .map(|level| {
                level
                    .iter()
                    .filter(|name| temporal_set.contains(name.as_str()))
                    .cloned()
                    .collect::<Vec<_>>()
            })
            .filter(|level| !level.is_empty())
            .collect();

        // Pass 1: evaluate temporal signals per-symbol.
        // Within each symbol, signals at the same dependency level are evaluated
        // in parallel (e.g. EMA12, EMA26, RSI14 are all level-0 and independent).
        let pass1_results: Vec<(SymbolId, HashMap<String, ArrayRef>)> = universe_sorted
            .par_iter()
            .map(|&symbol| {
                let signal_bars = if let Some(ref coarse) = coarse_bars {
                    coarse.get(&symbol).ok_or_else(|| {
                        BtError::Data(format!("missing coarse bars for {:?}", symbol))
                    })?
                } else {
                    aligned.symbol_bars.get(&symbol).ok_or_else(|| {
                        BtError::Data(format!("missing aligned bars for {:?}", symbol))
                    })?
                };

                let mut env = batch_as_env(signal_bars);

                for level in &temporal_levels {
                    if level.len() >= 2 {
                        // Parallel evaluation of independent signals at this level
                        let results: Vec<(String, ArrayRef)> = level
                            .par_iter()
                            .map(|name| {
                                let compiled =
                                    strategy.signals.get(name.as_str()).ok_or_else(|| {
                                        BtError::Strategy(format!(
                                            "missing compiled signal for '{name}'"
                                        ))
                                    })?;
                                let outputs = self.evaluator.evaluate(
                                    compiled,
                                    &env,
                                    &strategy.parameters,
                                )?;
                                let output = outputs
                                    .get("output")
                                    .or_else(|| outputs.values().next())
                                    .cloned()
                                    .ok_or_else(|| {
                                        BtError::Expression(format!(
                                            "signal '{name}' produced no output"
                                        ))
                                    })?;
                                Ok((name.clone(), output))
                            })
                            .collect::<Result<Vec<_>>>()?;
                        for (name, output) in results {
                            env.insert(name, output);
                        }
                    } else {
                        // Single signal at this level — no parallelism overhead
                        for name in level {
                            let compiled =
                                strategy.signals.get(name.as_str()).ok_or_else(|| {
                                    BtError::Strategy(format!(
                                        "missing compiled signal for '{name}'"
                                    ))
                                })?;
                            let outputs =
                                self.evaluator
                                    .evaluate(compiled, &env, &strategy.parameters)?;
                            let output = outputs
                                .get("output")
                                .or_else(|| outputs.values().next())
                                .cloned()
                                .ok_or_else(|| {
                                    BtError::Expression(format!(
                                        "signal '{name}' produced no output"
                                    ))
                                })?;
                            env.insert(name.clone(), output);
                        }
                    }
                }

                Ok((symbol, env))
            })
            .collect::<Result<Vec<_>>>()?;

        let mut symbol_envs: HashMap<SymbolId, HashMap<String, ArrayRef>> =
            pass1_results.into_iter().collect();

        // Pass 2: evaluate cross-sectional signals (sequential, across symbols).
        for cs_name in &cross_section_signals {
            let compiled = strategy.signals.get(cs_name.as_str()).ok_or_else(|| {
                BtError::Strategy(format!("missing compiled signal for '{cs_name}'"))
            })?;
            let cs_info = extract_cross_section_info(compiled)?.ok_or_else(|| {
                BtError::Expression(format!(
                    "signal '{cs_name}' marked as cross-sectional but no cross-section op found"
                ))
            })?;

            // Collect the input column from each symbol's env.
            let input_arrays: Vec<Float64Array> = universe_sorted
                .iter()
                .map(|&sym| {
                    let env = symbol_envs
                        .get(&sym)
                        .ok_or_else(|| BtError::Data(format!("missing env for {:?}", sym)))?;
                    let arr = env.get(&cs_info.input_column).ok_or_else(|| {
                        BtError::Expression(format!(
                            "cross-sectional signal '{cs_name}' references column '{}' \
                             which is not available in symbol {:?} env",
                            cs_info.input_column, sym
                        ))
                    })?;
                    arr.as_any()
                        .downcast_ref::<Float64Array>()
                        .cloned()
                        .ok_or_else(|| {
                            BtError::Expression(format!(
                                "cross-sectional input '{}' must be Float64Array",
                                cs_info.input_column
                            ))
                        })
                })
                .collect::<Result<Vec<_>>>()?;

            let refs: Vec<&Float64Array> = input_arrays.iter().collect();
            let results = match cs_info.op {
                CrossSectionKind::Mean => cross_sectional_mean_multi(&refs),
                CrossSectionKind::Rank => cross_sectional_rank_multi(&refs),
            };

            // Inject results back into each symbol's env.
            for (i, &sym) in universe_sorted.iter().enumerate() {
                let env = symbol_envs.get_mut(&sym).unwrap();
                env.insert(cs_name.clone(), Arc::new(results[i].clone()));
            }
        }

        // Pass 2b: resolve SymbolRef signals (sequential, across symbols).
        if !symbol_ref_signals.is_empty() {
            let name_to_id: HashMap<String, SymbolId> = if config.symbol_names.is_empty() {
                universe_sorted
                    .iter()
                    .map(|&sid| (sid.0.to_string(), sid))
                    .collect()
            } else {
                config.symbol_names.clone()
            };

            for sr_name in &symbol_ref_signals {
                let compiled = strategy.signals.get(sr_name.as_str()).ok_or_else(|| {
                    BtError::Strategy(format!("missing compiled signal for '{sr_name}'"))
                })?;
                let sr_info = extract_symbol_ref_info(compiled)?.ok_or_else(|| {
                    BtError::Expression(format!(
                        "signal '{sr_name}' marked as symbol-ref but no SymbolRef op found"
                    ))
                })?;

                let target_sid = name_to_id.get(&sr_info.symbol).ok_or_else(|| {
                    BtError::Expression(format!(
                        "SymbolRef in signal '{sr_name}' references symbol '{}' which is not \
                         in the universe or symbol_names mapping",
                        sr_info.symbol
                    ))
                })?;

                let source_array = {
                    let source_env = symbol_envs.get(target_sid).ok_or_else(|| {
                        BtError::Data(format!("missing env for {:?}", target_sid))
                    })?;
                    source_env
                        .get(&sr_info.input_column)
                        .ok_or_else(|| {
                            BtError::Expression(format!(
                                "SymbolRef signal '{sr_name}' references column '{}' \
                                 which is not available in symbol '{}' ({:?}) env",
                                sr_info.input_column, sr_info.symbol, target_sid
                            ))
                        })?
                        .clone()
                };

                for &sym in universe_sorted.iter() {
                    let env = symbol_envs.get_mut(&sym).unwrap();
                    env.insert(sr_name.clone(), source_array.clone());
                }
            }
        }

        // Pass 3: evaluate composite symbol-ref signals per-symbol.
        // These contain SymbolRef embedded in larger expressions. We rewrite each
        // SymbolRef(sym, Column(col)) → Column("__sr_{sym}_{col}") and inject the
        // source data under that synthetic key so the standard evaluator works.
        if !symbol_ref_composite_signals.is_empty() {
            let name_to_id: HashMap<String, SymbolId> = if config.symbol_names.is_empty() {
                universe_sorted
                    .iter()
                    .map(|&sid| (sid.0.to_string(), sid))
                    .collect()
            } else {
                config.symbol_names.clone()
            };

            // Pre-compute patched expressions and collect needed injections.
            let mut patched_exprs: Vec<(String, bt_expr::CompiledExpr)> = Vec::new();
            let mut injections: Vec<(String, String, SymbolId)> = Vec::new(); // (synthetic_key, source_col, source_sid)

            for name in &symbol_ref_composite_signals {
                let compiled = strategy.signals.get(name.as_str()).ok_or_else(|| {
                    BtError::Strategy(format!("missing compiled signal for '{name}'"))
                })?;
                let mut patched = compiled.clone();

                // First pass: collect all SymbolRef rewrites before mutating.
                let mut rewrites: Vec<(usize, usize, String, String, SymbolId)> = Vec::new();
                for node_idx in 0..patched.nodes.len() {
                    let symbol = match &patched.nodes[node_idx].op {
                        bt_expr::CompiledOp::SymbolRef { symbol } => symbol.clone(),
                        _ => continue,
                    };

                    let input_idx = patched.nodes[node_idx].inputs[0];
                    let input_col = match &patched.nodes[input_idx].op {
                        bt_expr::CompiledOp::Column(col) => col.clone(),
                        _ => continue,
                    };

                    let synthetic_key = format!("__sr_{symbol}_{input_col}");
                    let target_sid = name_to_id.get(&symbol).ok_or_else(|| {
                        BtError::Expression(format!(
                            "SymbolRef in composite signal '{name}' references symbol '{symbol}' \
                             which is not in the universe or symbol_names mapping"
                        ))
                    })?;

                    rewrites.push((node_idx, input_idx, synthetic_key, input_col, *target_sid));
                }

                // Second pass: apply all rewrites.
                // IMPORTANT: We must NOT mutate nodes[input_idx] in-place because
                // CSE (common subexpression elimination) may share that node with
                // other references (e.g. the symbol's own Column("close")).
                // Instead, replace only the SymbolRef node with a fresh Column node.
                for (node_idx, _input_idx, synthetic_key, input_col, target_sid) in rewrites {
                    injections.push((synthetic_key.clone(), input_col, target_sid));
                    patched.nodes[node_idx] = CompiledNode {
                        op: bt_expr::CompiledOp::Column(synthetic_key),
                        inputs: vec![],
                    };
                }

                patched_exprs.push((name.clone(), patched));
            }

            // Pre-collect source arrays (avoids borrow conflict).
            let mut injection_data: HashMap<String, ArrayRef> = HashMap::new();
            for (synthetic_key, source_col, source_sid) in &injections {
                if !injection_data.contains_key(synthetic_key) {
                    let source_env = symbol_envs.get(source_sid).ok_or_else(|| {
                        BtError::Data(format!("missing env for source {:?}", source_sid))
                    })?;
                    let source_arr = source_env.get(source_col).ok_or_else(|| {
                        BtError::Expression(format!(
                            "source column '{source_col}' not found in {:?} env",
                            source_sid
                        ))
                    })?;
                    injection_data.insert(synthetic_key.clone(), source_arr.clone());
                }
            }

            // Inject and evaluate.
            for &symbol in universe_sorted {
                let env = symbol_envs
                    .get_mut(&symbol)
                    .ok_or_else(|| BtError::Data(format!("missing env for {:?}", symbol)))?;

                for (key, arr) in &injection_data {
                    env.entry(key.clone()).or_insert_with(|| arr.clone());
                }

                for (name, patched) in &patched_exprs {
                    let outputs = self
                        .evaluator
                        .evaluate(patched, env, &strategy.parameters)?;
                    let output = outputs
                        .get("output")
                        .or_else(|| outputs.values().next())
                        .cloned()
                        .ok_or_else(|| {
                            BtError::Expression(format!("signal '{name}' produced no output"))
                        })?;
                    env.insert(name.clone(), output);
                }
            }
        }

        // Evaluate position sizing per-symbol using enriched envs.
        let mut symbol_targets: HashMap<SymbolId, Float64Array> = HashMap::new();
        for &symbol in universe_sorted {
            let env = symbol_envs
                .get(&symbol)
                .ok_or_else(|| BtError::Data(format!("missing env for {:?}", symbol)))?;

            let sizing_outputs =
                self.evaluator
                    .evaluate(&strategy.position_sizing, env, &strategy.parameters)?;
            let targets = sizing_outputs
                .get("output")
                .or_else(|| sizing_outputs.values().next())
                .cloned()
                .ok_or_else(|| {
                    BtError::Expression("position sizing produced no output".to_string())
                })?;
            let targets = targets
                .as_any()
                .downcast_ref::<Float64Array>()
                .ok_or_else(|| {
                    BtError::Expression(
                        "position sizing expression must evaluate to Float64Array".to_string(),
                    )
                })?
                .clone();

            let targets = if let Some(ref groups) = resample_groups {
                expand_to_fine_resolution(&targets, groups, num_bars)
            } else {
                targets
            };
            symbol_targets.insert(symbol, targets);
        }

        profile.signal_eval_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = profile.signal_eval_us,
            signals = strategy.signals.len(),
            "phase 3/6: signals evaluated"
        );

        // --- 4. Build per-symbol BarsViews and funding rates (always fine bars) ---
        let t_phase = std::time::Instant::now();
        // Simulation iterates over native (fine) bars, so bar_seconds must
        // reflect the native resolution, not the signal evaluation interval.
        let bar_seconds = native_ns as f64 / 1_000_000_000.0;
        let delay = usize::try_from(config.execution.signal_delay)
            .map_err(|_| BtError::Strategy("signal_delay conversion overflow".to_string()))?;
        let is_pyramiding = config.execution.pyramiding;

        struct SymbolRuntime<'a> {
            bars_view: BarsView<'a>,
            funding_rates: Option<Float64Array>,
        }

        let mut symbol_rt: HashMap<SymbolId, SymbolRuntime<'_>> = HashMap::new();
        for &symbol in universe_sorted {
            let batch = aligned
                .symbol_bars
                .get(&symbol)
                .ok_or_else(|| BtError::Data(format!("missing bars for {:?}", symbol)))?;
            let bars_view = BarsView::from_batch(batch)?;
            let funding_rates = resolve_funding_rates(batch, config, &mut warnings)?;
            symbol_rt.insert(
                symbol,
                SymbolRuntime {
                    bars_view,
                    funding_rates,
                },
            );
        }

        profile.runtime_prep_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = profile.runtime_prep_us,
            "phase 4/6: runtime prepared"
        );

        // --- 5. Sequential multi-asset simulation (Vec-indexed hot loop) ---
        let t_phase = std::time::Instant::now();
        let n_sym = universe_sorted.len();

        let close_slices: Vec<&[f64]> = universe_sorted
            .iter()
            .map(|s| symbol_rt[s].bars_view.close.values().as_ref())
            .collect();
        let target_slices: Vec<&[f64]> = universe_sorted
            .iter()
            .map(|s| symbol_targets[s].values().as_ref())
            .collect();
        let target_nulls: Vec<_> = universe_sorted
            .iter()
            .map(|s| symbol_targets[s].nulls())
            .collect();
        let funding_slices: Vec<Option<&[f64]>> = universe_sorted
            .iter()
            .map(|s| {
                symbol_rt[s]
                    .funding_rates
                    .as_ref()
                    .map(|f| f.values().as_ref())
            })
            .collect();
        let funding_nulls: Vec<_> = universe_sorted
            .iter()
            .map(|s| symbol_rt[s].funding_rates.as_ref().and_then(|f| f.nulls()))
            .collect();
        let bars_views: Vec<&BarsView<'_>> = universe_sorted
            .iter()
            .map(|s| &symbol_rt[s].bars_view)
            .collect();

        let use_fast_path = can_use_fast_path(config);
        let sim = if use_fast_path {
            tracing::debug!("using fast-path simulation kernel");
            simulate_fast(
                num_bars,
                n_sym,
                &close_slices,
                &target_slices,
                &target_nulls,
                &bars_views,
                universe_sorted,
                config,
                delay,
            )?
        } else {
            let fill_model = &config.execution.fill_model;

            let mut capital = config.initial_capital;
            let mut positions: Vec<f64> = vec![0.0; n_sym];
            let mut pending: Vec<Option<PendingOrder>> = vec![None; n_sym];
            let mut bar_closes: Vec<f64> = vec![f64::NAN; n_sym];
            // Carry-forward: last known close price per symbol for equity valuation
            // during gap bars (NaN close). Without this, equity would exclude the
            // position's value on gap bars, causing phantom drawdowns.
            let mut last_known_close: Vec<f64> = vec![f64::NAN; n_sym];
            // Track last raw target (pre-unit-conversion) per symbol to avoid
            // continuous rebalancing in FractionOfEquity mode where the same
            // fraction maps to slightly different unit counts each bar.
            let mut last_raw_target: Vec<f64> = vec![f64::NAN; n_sym];

            let mut trace_positions: Vec<Vec<f64>> =
                (0..n_sym).map(|_| Vec::with_capacity(num_bars)).collect();
            let mut trace_closes: Vec<Vec<f64>> =
                (0..n_sym).map(|_| Vec::with_capacity(num_bars)).collect();
            let mut trace_capitals: Vec<f64> = Vec::with_capacity(num_bars);
            let mut trace_equities: Vec<f64> = Vec::with_capacity(num_bars);

            let mut trade_signal_ts = Vec::new();
            let mut trade_exec_ts = Vec::new();
            let mut trade_symbol_ids = Vec::new();
            let mut trade_sides = Vec::new();
            let mut trade_qty = Vec::new();
            let mut trade_intended = Vec::new();
            let mut trade_fill = Vec::new();
            let mut trade_fees = Vec::new();
            let mut trade_slippage = Vec::new();
            let mut trade_gap = Vec::new();
            let mut trade_exit_reasons: Vec<u8> = Vec::new();

            // Order management state.
            let _has_orders = !config.execution.orders.is_empty();
            let has_exits = config.execution.orders.has_exit_orders();
            let mut brackets: Vec<Option<ActiveBracket>> = vec![None; n_sym];

            for row in 0..num_bars {
                for si in 0..n_sym {
                    let raw = close_slices[si][row];
                    if !raw.is_nan() {
                        bar_closes[si] = raw;
                        last_known_close[si] = raw;
                    } else {
                        // Gap bar: carry forward last known close for valuation.
                        bar_closes[si] = last_known_close[si];
                    }
                }

                let mut current_equity = capital;
                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if !c.is_nan() {
                        current_equity += positions[si] * c;
                    }
                }

                // --- Phase B0: Update trailing stops ---
                if has_exits {
                    if let Some(ref ts_cfg) = config.execution.orders.trailing_stop {
                        for si in 0..n_sym {
                            if let Some(ref mut bracket) = brackets[si] {
                                let bv = bars_views[si];
                                if !bv.high.is_null(row) && !bv.low.is_null(row) {
                                    let h = bv.high.value(row);
                                    let l = bv.low.value(row);
                                    bracket.update_trail(h, l, ts_cfg.trail_pct, ts_cfg.use_high);
                                }
                            }
                        }
                    }
                }

                // --- Phase B1: Check SL / TP triggers (before signal processing) ---
                if has_exits {
                    for si in 0..n_sym {
                        if positions[si].abs() < 1e-12 {
                            continue;
                        }
                        let Some(ref bracket) = brackets[si] else {
                            continue;
                        };
                        let bv = bars_views[si];
                        if bv.high.is_null(row) || bv.low.is_null(row) {
                            continue;
                        }
                        let bar_high = bv.high.value(row);
                        let bar_low = bv.low.value(row);

                        let stop_hit = bracket.check_stop(bar_high, bar_low);
                        let tp_hit = bracket.check_tp(bar_high, bar_low);

                        // SL is prioritised when both trigger on the same bar (conservative).
                        if let Some((trigger_price, exit_reason)) = stop_hit {
                            let exit_qty = positions[si].abs();
                            let side = if positions[si] > 0.0 {
                                Side::Sell
                            } else {
                                Side::Buy
                            };
                            let bar = bar_data(bv, row)?;
                            let fill_price = config.slippage.adjusted_price(
                                trigger_price,
                                exit_qty,
                                side,
                                &bar,
                                &mut warnings,
                            );
                            let fill_price = fill_price.clamp(bar_low, bar_high);
                            let fees =
                                compute_fees(exit_qty, fill_price, &config.fees, FillType::Taker);

                            let signed_fill = if side == Side::Buy {
                                exit_qty
                            } else {
                                -exit_qty
                            };
                            capital -= signed_fill * fill_price + fees;
                            positions[si] += signed_fill;

                            // Record trade.
                            trade_signal_ts.push(bv.timestamp_value(row));
                            trade_exec_ts.push(bv.timestamp_value(row));
                            trade_symbol_ids.push(universe_sorted[si].0);
                            trade_sides.push(match side {
                                Side::Buy => 1_u8,
                                Side::Sell => 2_u8,
                            });
                            trade_qty.push(exit_qty);
                            trade_intended.push(trigger_price);
                            trade_fill.push(fill_price);
                            trade_fees.push(fees);
                            trade_slippage.push((fill_price - trigger_price).abs());
                            trade_gap.push(bar.is_gap);
                            trade_exit_reasons.push(exit_reason.as_u8());

                            brackets[si] = None;
                            pending[si] = None;
                            last_raw_target[si] = f64::NAN;
                        } else if let Some(tp_price) = tp_hit {
                            let exit_qty = positions[si].abs();
                            let side = if positions[si] > 0.0 {
                                Side::Sell
                            } else {
                                Side::Buy
                            };
                            // TP fills at limit price → maker fees.
                            let fees =
                                compute_fees(exit_qty, tp_price, &config.fees, FillType::Maker);

                            let signed_fill = if side == Side::Buy {
                                exit_qty
                            } else {
                                -exit_qty
                            };
                            capital -= signed_fill * tp_price + fees;
                            positions[si] += signed_fill;

                            let bv = bars_views[si];
                            trade_signal_ts.push(bv.timestamp_value(row));
                            trade_exec_ts.push(bv.timestamp_value(row));
                            trade_symbol_ids.push(universe_sorted[si].0);
                            trade_sides.push(match side {
                                Side::Buy => 1_u8,
                                Side::Sell => 2_u8,
                            });
                            trade_qty.push(exit_qty);
                            trade_intended.push(tp_price);
                            trade_fill.push(tp_price);
                            trade_fees.push(fees);
                            trade_slippage.push(0.0);
                            trade_gap.push(false);
                            trade_exit_reasons.push(ExitReason::TakeProfit.as_u8());

                            brackets[si] = None;
                            pending[si] = None;
                            last_raw_target[si] = f64::NAN;
                        }
                    }
                }

                // Create new pending orders from delayed signals.
                let warmup = config.warmup_bars as usize;
                for si in 0..n_sym {
                    if row < delay + warmup {
                        continue;
                    }
                    // Skip if a multi-bar fill is still in progress (no new signal
                    // until current order completes or a different target appears).
                    if pending[si].is_some() {
                        // Allow override only when raw target actually changed
                        // (in delta mode, always allow override for non-null signals).
                        let signal_row = row - delay;
                        let is_null = target_nulls[si]
                            .as_ref()
                            .is_some_and(|nb| nb.is_null(signal_row));
                        if !is_null {
                            let raw_target = target_slices[si][signal_row];
                            if raw_target.is_nan() {
                                continue;
                            }
                            if !is_pyramiding && (raw_target - last_raw_target[si]).abs() < 1e-12 {
                                continue;
                            }
                            // New signal differs (or delta mode) — override below.
                        } else {
                            continue;
                        }
                    }
                    let signal_row = row - delay;
                    let is_null = target_nulls[si]
                        .as_ref()
                        .is_some_and(|nb| nb.is_null(signal_row));
                    if is_null {
                        continue;
                    }
                    let raw_target = target_slices[si][signal_row];
                    if raw_target.is_nan() {
                        continue;
                    }
                    if !is_pyramiding
                        && !last_raw_target[si].is_nan()
                        && (raw_target - last_raw_target[si]).abs() < 1e-12
                    {
                        continue;
                    }
                    last_raw_target[si] = raw_target;
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let current_pos = positions[si];
                    let desired = sanitize_target(
                        raw_target,
                        &config.execution,
                        c,
                        current_equity,
                        config.initial_capital,
                        current_pos,
                    );
                    let delta = desired - current_pos;
                    if delta.abs() > 1e-12 {
                        let (lp, tif) = if let Some(ref lim) = config.execution.orders.limit_entry {
                            let dir = if delta > 0.0 { -1.0 } else { 1.0 };
                            let price = c * (1.0 + dir * lim.offset_bps / 10_000.0);
                            (Some(price), lim.time_in_force.clone())
                        } else {
                            (None, TimeInForce::GTC)
                        };
                        // When a new signal overrides an existing bracket, clear it.
                        if has_exits {
                            brackets[si] = None;
                        }
                        pending[si] = Some(PendingOrder {
                            signal_row,
                            remaining_delta: delta,
                            fill_price_x_qty: 0.0,
                            total_filled: 0.0,
                            total_fees: 0.0,
                            total_slippage: 0.0,
                            limit_price: lp,
                            bars_alive: 0,
                            time_in_force: tif,
                            is_buy: delta > 0.0,
                        });
                    }
                }

                // Execute pending orders (including newly created ones).
                for si in 0..n_sym {
                    let Some(ref mut order) = pending[si] else {
                        continue;
                    };
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let bv = bars_views[si];
                    if config.execution.skip_gap_bars && bv.is_gap_at(row) {
                        warnings.push(format!(
                            "trade deferred for {:?} due to gap bar at {}",
                            universe_sorted[si],
                            bv.timestamp_value(row)
                        ));
                    } else {
                        // --- Limit order gate ---
                        let is_limit = order.limit_price.is_some();
                        #[allow(unused_assignments)]
                        let mut limit_can_fill = true;
                        if let Some(limit) = order.limit_price {
                            let h = bv.high.value(row);
                            let l = bv.low.value(row);
                            limit_can_fill = if order.is_buy {
                                l <= limit // Buy limit: triggered when low <= limit
                            } else {
                                h >= limit // Sell limit: triggered when high >= limit
                            };
                            if !limit_can_fill {
                                order.bars_alive += 1;
                                match &order.time_in_force {
                                    TimeInForce::IOC => {
                                        pending[si] = None;
                                        continue;
                                    }
                                    TimeInForce::GTB(n) => {
                                        if order.bars_alive >= *n {
                                            pending[si] = None;
                                            continue;
                                        }
                                    }
                                    TimeInForce::GTC => {}
                                }
                                continue;
                            }
                        }

                        let bar = bar_data(bv, row)?;

                        // For limit fills, use the limit price; for market, resolve normally.
                        let intended = if is_limit {
                            order.limit_price.unwrap()
                        } else {
                            let base = resolve_execution_price(
                                bv,
                                row,
                                &config.execution.execution_price,
                                &mut warnings,
                            )?;
                            approximate_intra_bar_price(bv, row, &fill_model.intra_bar_price, base)
                        };

                        let abs_remaining = order.remaining_delta.abs();
                        let fill_qty = if fill_model.max_participation_rate > 0.0 {
                            let max_qty = bar.volume * fill_model.max_participation_rate;
                            abs_remaining.min(max_qty)
                        } else {
                            abs_remaining
                        };

                        if fill_qty > 1e-15 {
                            let side = if order.is_buy { Side::Buy } else { Side::Sell };

                            // Limit fills → maker fees, market fills → default (taker).
                            let (fill_price, fill_type) = if is_limit {
                                (intended, FillType::Maker)
                            } else {
                                let fp = config.slippage.adjusted_price(
                                    intended,
                                    fill_qty,
                                    side,
                                    &bar,
                                    &mut warnings,
                                );
                                (fp, config.fees.default_fill_type)
                            };

                            validate_fill_price(bv, row, fill_price, &mut warnings);
                            let fees = compute_fees(fill_qty, fill_price, &config.fees, fill_type);
                            let slippage_val = (fill_price - intended).abs();

                            let signed_fill = if side == Side::Buy {
                                fill_qty
                            } else {
                                -fill_qty
                            };
                            capital -= signed_fill * fill_price + fees;
                            positions[si] += signed_fill;
                            order.remaining_delta -= signed_fill;
                            order.fill_price_x_qty += fill_price * fill_qty;
                            order.total_filled += fill_qty;
                            order.total_fees += fees;
                            order.total_slippage += slippage_val * fill_qty;

                            if bar.is_gap {
                                warnings.push(format!(
                                    "trade executed on gap bar for {:?} at {}",
                                    universe_sorted[si],
                                    bv.timestamp_value(row)
                                ));
                            }
                        }

                        // Order complete when remaining is negligible.
                        if order.remaining_delta.abs() < 1e-12 || fill_qty < 1e-15 {
                            if order.total_filled > 1e-15 {
                                let avg_fill = order.fill_price_x_qty / order.total_filled;
                                let side = if order.is_buy { Side::Buy } else { Side::Sell };
                                trade_signal_ts.push(bv.timestamp_value(order.signal_row));
                                trade_exec_ts.push(bv.timestamp_value(row));
                                trade_symbol_ids.push(universe_sorted[si].0);
                                trade_sides.push(match side {
                                    Side::Buy => 1_u8,
                                    Side::Sell => 2_u8,
                                });
                                trade_qty.push(order.total_filled);
                                trade_intended.push(avg_fill);
                                trade_fill.push(avg_fill);
                                trade_fees.push(order.total_fees);
                                trade_slippage.push(order.total_slippage / order.total_filled);
                                trade_gap.push(bar.is_gap);
                                trade_exit_reasons.push(ExitReason::Signal.as_u8());

                                // Create bracket for new positions.
                                if has_exits && positions[si].abs() > 1e-12 {
                                    brackets[si] = ActiveBracket::new(
                                        avg_fill,
                                        positions[si],
                                        &config.execution.orders,
                                    );
                                } else if positions[si].abs() < 1e-12 {
                                    brackets[si] = None;
                                }
                            }
                            pending[si] = None;
                        }
                    }
                }

                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let pos = positions[si];
                    if let Some(fr) = funding_slices[si] {
                        let is_null = funding_nulls[si].as_ref().is_some_and(|nb| nb.is_null(row));
                        if !is_null {
                            capital += funding_pnl(pos, c, fr[row]);
                        }
                    }
                    capital -= borrow_cost(pos, c, config.fees.borrow_rate_annual_bps, bar_seconds);
                }

                let mut equity = capital;
                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if !c.is_nan() {
                        equity += positions[si] * c;
                    }
                }
                for si in 0..n_sym {
                    trace_positions[si].push(positions[si]);
                    trace_closes[si].push(bar_closes[si]);
                }
                trace_capitals.push(capital);
                trace_equities.push(equity);
            }

            SimResult {
                trace_positions,
                trace_closes,
                trace_capitals,
                trace_equities,
                trade_signal_ts,
                trade_exec_ts,
                trade_symbol_ids,
                trade_sides,
                trade_qty,
                trade_intended,
                trade_fill,
                trade_fees,
                trade_slippage,
                trade_gap,
                trade_exit_reasons,
                warnings: Vec::new(), // general path writes to outer `warnings` directly
            }
        }; // end if/else fast_path

        // Fast path returns warnings inside SimResult; general path writes directly
        // to the outer `warnings` vec, so only extend for fast-path results.
        if use_fast_path {
            warnings.extend(sim.warnings);
        }

        let SimResult {
            trace_positions,
            trace_closes,
            trace_capitals,
            trace_equities,
            trade_signal_ts,
            trade_exec_ts,
            trade_symbol_ids,
            trade_sides,
            trade_qty,
            trade_intended,
            trade_fill,
            trade_fees,
            trade_slippage,
            trade_gap,
            trade_exit_reasons,
            ..
        } = sim;

        profile.simulation_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = profile.simulation_us,
            bars = num_bars,
            "phase 5/6: simulation complete"
        );

        // --- 6. Build output ---
        let t_phase = std::time::Instant::now();
        let mut metrics = compute_performance_metrics_with_rf(
            &trace_equities,
            master_timestamps,
            bar_seconds,
            config.trading_days_per_year,
            config.risk_free_rate,
        )?;

        // CAPM alpha: regress strategy daily returns on benchmark (equal-weight buy-and-hold).
        {
            let close_slices: Vec<&[f64]> = universe_sorted
                .iter()
                .filter_map(|s| {
                    aligned.symbol_bars.get(s).and_then(|b| {
                        b.column_by_name("close")?
                            .as_any()
                            .downcast_ref::<Float64Array>()
                            .map(|a| a.values().as_ref())
                    })
                })
                .collect();
            let benchmark_rets = compute_benchmark_daily_returns(&close_slices, master_timestamps);
            let strategy_daily = resample_to_daily(&trace_equities, master_timestamps);
            let strategy_rets: Vec<f64> = strategy_daily
                .windows(2)
                .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 })
                .collect();
            let ols = compute_capm_alpha(
                &strategy_rets,
                &benchmark_rets,
                config.trading_days_per_year,
            );
            metrics.alpha = ols.alpha;
            metrics.beta = ols.beta;
            metrics.tstat_alpha = ols.tstat_alpha;
        }

        let daily_returns = Arc::new(Float64Array::from(compute_daily_returns(
            &trace_equities,
            master_timestamps,
        ))) as ArrayRef;

        // Build fills for round-trip trade analysis before moving trade vectors.
        let fills: Vec<(u32, i64, u8, f64, f64, f64)> = (0..trade_exec_ts.len())
            .map(|i| {
                (
                    trade_symbol_ids[i],
                    trade_exec_ts[i],
                    trade_sides[i],
                    trade_qty[i],
                    trade_fill[i],
                    trade_fees[i],
                )
            })
            .collect();
        let exit_reasons_ref: Option<&[u8]> = if trade_exit_reasons.is_empty() {
            None
        } else {
            Some(&trade_exit_reasons)
        };
        let trade_stats = compute_trade_statistics(&fills, exit_reasons_ref);
        metrics.trade_stats = Some(trade_stats);

        let output_nanos = effective_output_nanos(config);
        let sample_indices = downsample_indices(master_timestamps, output_nanos);

        let sampled_timestamps: Vec<i64> = sample_indices
            .iter()
            .map(|&i| master_timestamps[i])
            .collect();
        let sampled_positions: Vec<Vec<f64>> = trace_positions
            .iter()
            .map(|pos| sample_indices.iter().map(|&i| pos[i]).collect())
            .collect();
        let sampled_closes: Vec<Vec<f64>> = trace_closes
            .iter()
            .map(|cls| sample_indices.iter().map(|&i| cls[i]).collect())
            .collect();
        let sampled_capitals: Vec<f64> =
            sample_indices.iter().map(|&i| trace_capitals[i]).collect();
        let sampled_equities: Vec<f64> =
            sample_indices.iter().map(|&i| trace_equities[i]).collect();

        let equity_curve = Arc::new(Float64Array::from(sampled_equities.clone())) as ArrayRef;
        let positions_batch = build_positions_batch(
            &sampled_timestamps,
            universe_sorted,
            &sampled_positions,
            &sampled_closes,
            &sampled_capitals,
            &sampled_equities,
        )?;
        let trades = build_trades_batch(
            trade_signal_ts,
            trade_exec_ts,
            trade_symbol_ids,
            trade_sides,
            trade_qty,
            trade_intended,
            trade_fill,
            trade_fees,
            trade_slippage,
            trade_gap,
            trade_exit_reasons,
        )?;

        let mut data_versions = HashMap::new();
        data_versions.insert("bars_1m".to_string(), data_version.to_string());

        let manifest = build_run_manifest(strategy, config, data_versions)?;
        profile.output_build_us = t_phase.elapsed().as_micros() as u64;
        tracing::info!(
            elapsed_ms = profile.output_build_us,
            "phase 6/6: output built"
        );

        Ok(BacktestResult {
            manifest,
            equity_curve,
            positions: positions_batch,
            trades,
            daily_returns,
            metrics,
            warnings,
            profile,
        })
    }

    /// Lightweight run: signals + simulation → metrics only.
    /// Skips trade logging, position traces, and Arrow output building.
    /// Memory per strategy: ~20MB (signal arrays) instead of ~86MB.
    pub fn run_lite_on_aligned(
        &self,
        strategy: &StrategyPlan,
        config: &BacktestConfig,
        aligned: &AlignedBars,
        universe_sorted: &[SymbolId],
    ) -> Result<BatchResultLite> {
        let mut profile = ProfileData::default();
        let master_timestamps = &aligned.master_timestamps;
        // --- Hybrid resampling setup ---
        let native_ns = detect_native_interval_ns(master_timestamps);
        let signal_interval = config.resample_to.as_ref().unwrap_or(&config.bar_interval);
        let signal_ns = signal_interval.as_nanos();

        let (coarse_bars, resample_groups) = if signal_ns > native_ns {
            let groups = compute_resample_groups(master_timestamps, signal_ns);
            let coarse_len = groups.len();
            let coarse: HashMap<SymbolId, RecordBatch> = aligned
                .symbol_bars
                .par_iter()
                .map(|(&symbol, batch)| {
                    resample_batch(batch, &groups, coarse_len).map(|rb| (symbol, rb))
                })
                .collect::<Result<_>>()?;
            (Some(coarse), Some(groups))
        } else {
            (None, None)
        };

        // --- 3. Evaluate signals and position sizing (2-pass if cross-sectional) ---
        let t_signal = std::time::Instant::now();
        let signal_order = topological_signal_order(&strategy.signals)?;

        let mut temporal_signals = Vec::new();
        let mut cross_section_signals = Vec::new();
        let mut symbol_ref_signals = Vec::new();
        let mut symbol_ref_composite_signals = Vec::new();
        for name in &signal_order {
            let compiled = strategy.signals.get(name.as_str()).ok_or_else(|| {
                BtError::Strategy(format!("missing compiled signal for '{name}'"))
            })?;
            if has_cross_sectional_ops(compiled) {
                cross_section_signals.push(name.clone());
            } else if has_symbol_ref_ops(compiled) {
                match extract_symbol_ref_info(compiled) {
                    Ok(Some(_)) => symbol_ref_signals.push(name.clone()),
                    _ => symbol_ref_composite_signals.push(name.clone()),
                }
            } else {
                temporal_signals.push(name.clone());
            }
        }

        // Pass 1: evaluate temporal signals per-symbol (parallel).
        let pass1_results: Vec<(SymbolId, HashMap<String, ArrayRef>)> = universe_sorted
            .par_iter()
            .map(|&symbol| {
                let signal_bars = if let Some(ref coarse) = coarse_bars {
                    coarse.get(&symbol).ok_or_else(|| {
                        BtError::Data(format!("missing coarse bars for {:?}", symbol))
                    })?
                } else {
                    aligned.symbol_bars.get(&symbol).ok_or_else(|| {
                        BtError::Data(format!("missing aligned bars for {:?}", symbol))
                    })?
                };

                let evaluator = DefaultExprEvaluator::new();
                let mut env = batch_as_env(signal_bars);

                for name in &temporal_signals {
                    let compiled = strategy.signals.get(name.as_str()).ok_or_else(|| {
                        BtError::Strategy(format!("missing compiled signal for '{name}'"))
                    })?;
                    let outputs = evaluator.evaluate(compiled, &env, &strategy.parameters)?;
                    let output = outputs
                        .get("output")
                        .or_else(|| outputs.values().next())
                        .cloned()
                        .ok_or_else(|| {
                            BtError::Expression(format!("signal '{name}' produced no output"))
                        })?;
                    env.insert(name.clone(), output);
                }

                Ok((symbol, env))
            })
            .collect::<Result<Vec<_>>>()?;

        let mut symbol_envs: HashMap<SymbolId, HashMap<String, ArrayRef>> =
            pass1_results.into_iter().collect();

        // Pass 2: evaluate cross-sectional signals (sequential, across symbols).
        for cs_name in &cross_section_signals {
            let compiled = strategy.signals.get(cs_name.as_str()).ok_or_else(|| {
                BtError::Strategy(format!("missing compiled signal for '{cs_name}'"))
            })?;
            let cs_info = extract_cross_section_info(compiled)?.ok_or_else(|| {
                BtError::Expression(format!(
                    "signal '{cs_name}' marked as cross-sectional but no cross-section op found"
                ))
            })?;

            let input_arrays: Vec<Float64Array> = universe_sorted
                .iter()
                .map(|&sym| {
                    let env = symbol_envs
                        .get(&sym)
                        .ok_or_else(|| BtError::Data(format!("missing env for {:?}", sym)))?;
                    let arr = env.get(&cs_info.input_column).ok_or_else(|| {
                        BtError::Expression(format!(
                            "cross-sectional signal '{cs_name}' references column '{}' \
                             which is not available in symbol {:?} env",
                            cs_info.input_column, sym
                        ))
                    })?;
                    arr.as_any()
                        .downcast_ref::<Float64Array>()
                        .cloned()
                        .ok_or_else(|| {
                            BtError::Expression(format!(
                                "cross-sectional input '{}' must be Float64Array",
                                cs_info.input_column
                            ))
                        })
                })
                .collect::<Result<Vec<_>>>()?;

            let refs: Vec<&Float64Array> = input_arrays.iter().collect();
            let results = match cs_info.op {
                CrossSectionKind::Mean => cross_sectional_mean_multi(&refs),
                CrossSectionKind::Rank => cross_sectional_rank_multi(&refs),
            };

            for (i, &sym) in universe_sorted.iter().enumerate() {
                let env = symbol_envs.get_mut(&sym).unwrap();
                env.insert(cs_name.clone(), Arc::new(results[i].clone()));
            }
        }

        // Pass 2b: resolve SymbolRef signals (sequential, across symbols).
        if !symbol_ref_signals.is_empty() {
            let name_to_id: HashMap<String, SymbolId> = if config.symbol_names.is_empty() {
                universe_sorted
                    .iter()
                    .map(|&sid| (sid.0.to_string(), sid))
                    .collect()
            } else {
                config.symbol_names.clone()
            };

            for sr_name in &symbol_ref_signals {
                let compiled = strategy.signals.get(sr_name.as_str()).ok_or_else(|| {
                    BtError::Strategy(format!("missing compiled signal for '{sr_name}'"))
                })?;
                let sr_info = extract_symbol_ref_info(compiled)?.ok_or_else(|| {
                    BtError::Expression(format!(
                        "signal '{sr_name}' marked as symbol-ref but no SymbolRef op found"
                    ))
                })?;

                let target_sid = name_to_id.get(&sr_info.symbol).ok_or_else(|| {
                    BtError::Expression(format!(
                        "SymbolRef in signal '{sr_name}' references symbol '{}' which is not \
                         in the universe or symbol_names mapping",
                        sr_info.symbol
                    ))
                })?;

                let source_array = {
                    let source_env = symbol_envs.get(target_sid).ok_or_else(|| {
                        BtError::Data(format!("missing env for {:?}", target_sid))
                    })?;
                    source_env
                        .get(&sr_info.input_column)
                        .ok_or_else(|| {
                            BtError::Expression(format!(
                                "SymbolRef signal '{sr_name}' references column '{}' \
                                 which is not available in symbol '{}' ({:?}) env",
                                sr_info.input_column, sr_info.symbol, target_sid
                            ))
                        })?
                        .clone()
                };

                for &sym in universe_sorted.iter() {
                    let env = symbol_envs.get_mut(&sym).unwrap();
                    env.insert(sr_name.clone(), source_array.clone());
                }
            }
        }

        // Pass 3: evaluate composite symbol-ref signals per-symbol.
        // These contain SymbolRef embedded in larger expressions. We rewrite each
        // SymbolRef(sym, Column(col)) → Column("__sr_{sym}_{col}") and inject the
        // source data under that synthetic key so the standard evaluator works.
        if !symbol_ref_composite_signals.is_empty() {
            let name_to_id: HashMap<String, SymbolId> = if config.symbol_names.is_empty() {
                universe_sorted
                    .iter()
                    .map(|&sid| (sid.0.to_string(), sid))
                    .collect()
            } else {
                config.symbol_names.clone()
            };

            // Pre-compute patched expressions and collect needed injections.
            let mut patched_exprs: Vec<(String, bt_expr::CompiledExpr)> = Vec::new();
            let mut injections: Vec<(String, String, SymbolId)> = Vec::new(); // (synthetic_key, source_col, source_sid)

            for name in &symbol_ref_composite_signals {
                let compiled = strategy.signals.get(name.as_str()).ok_or_else(|| {
                    BtError::Strategy(format!("missing compiled signal for '{name}'"))
                })?;
                let mut patched = compiled.clone();

                // First pass: collect all SymbolRef rewrites before mutating.
                let mut rewrites: Vec<(usize, usize, String, String, SymbolId)> = Vec::new();
                for node_idx in 0..patched.nodes.len() {
                    let symbol = match &patched.nodes[node_idx].op {
                        bt_expr::CompiledOp::SymbolRef { symbol } => symbol.clone(),
                        _ => continue,
                    };

                    let input_idx = patched.nodes[node_idx].inputs[0];
                    let input_col = match &patched.nodes[input_idx].op {
                        bt_expr::CompiledOp::Column(col) => col.clone(),
                        _ => continue,
                    };

                    let synthetic_key = format!("__sr_{symbol}_{input_col}");
                    let target_sid = name_to_id.get(&symbol).ok_or_else(|| {
                        BtError::Expression(format!(
                            "SymbolRef in composite signal '{name}' references symbol '{symbol}' \
                             which is not in the universe or symbol_names mapping"
                        ))
                    })?;

                    rewrites.push((node_idx, input_idx, synthetic_key, input_col, *target_sid));
                }

                // Second pass: apply all rewrites.
                // IMPORTANT: We must NOT mutate nodes[input_idx] in-place because
                // CSE (common subexpression elimination) may share that node with
                // other references (e.g. the symbol's own Column("close")).
                // Instead, replace only the SymbolRef node with a fresh Column node.
                for (node_idx, _input_idx, synthetic_key, input_col, target_sid) in rewrites {
                    injections.push((synthetic_key.clone(), input_col, target_sid));
                    patched.nodes[node_idx] = CompiledNode {
                        op: bt_expr::CompiledOp::Column(synthetic_key),
                        inputs: vec![],
                    };
                }

                patched_exprs.push((name.clone(), patched));
            }

            // Pre-collect source arrays (avoids borrow conflict).
            let mut injection_data: HashMap<String, ArrayRef> = HashMap::new();
            for (synthetic_key, source_col, source_sid) in &injections {
                if !injection_data.contains_key(synthetic_key) {
                    let source_env = symbol_envs.get(source_sid).ok_or_else(|| {
                        BtError::Data(format!("missing env for source {:?}", source_sid))
                    })?;
                    let source_arr = source_env.get(source_col).ok_or_else(|| {
                        BtError::Expression(format!(
                            "source column '{source_col}' not found in {:?} env",
                            source_sid
                        ))
                    })?;
                    injection_data.insert(synthetic_key.clone(), source_arr.clone());
                }
            }

            // Inject and evaluate.
            for &symbol in universe_sorted {
                let env = symbol_envs
                    .get_mut(&symbol)
                    .ok_or_else(|| BtError::Data(format!("missing env for {:?}", symbol)))?;

                for (key, arr) in &injection_data {
                    env.entry(key.clone()).or_insert_with(|| arr.clone());
                }

                for (name, patched) in &patched_exprs {
                    let outputs = self
                        .evaluator
                        .evaluate(patched, env, &strategy.parameters)?;
                    let output = outputs
                        .get("output")
                        .or_else(|| outputs.values().next())
                        .cloned()
                        .ok_or_else(|| {
                            BtError::Expression(format!("signal '{name}' produced no output"))
                        })?;
                    env.insert(name.clone(), output);
                }
            }
        }

        // Evaluate position sizing per-symbol using enriched envs.
        let mut symbol_targets: HashMap<SymbolId, Float64Array> = HashMap::new();
        for &symbol in universe_sorted {
            let env = symbol_envs
                .get(&symbol)
                .ok_or_else(|| BtError::Data(format!("missing env for {:?}", symbol)))?;

            let sizing_outputs =
                self.evaluator
                    .evaluate(&strategy.position_sizing, env, &strategy.parameters)?;
            let targets = sizing_outputs
                .get("output")
                .or_else(|| sizing_outputs.values().next())
                .cloned()
                .ok_or_else(|| {
                    BtError::Expression("position sizing produced no output".to_string())
                })?;
            let targets = targets
                .as_any()
                .downcast_ref::<Float64Array>()
                .ok_or_else(|| {
                    BtError::Expression(
                        "position sizing expression must evaluate to Float64Array".to_string(),
                    )
                })?
                .clone();

            // In lite mode, sim runs on coarse bars — no expansion needed.
            symbol_targets.insert(symbol, targets);
        }

        profile.signal_eval_us = t_signal.elapsed().as_micros() as u64;

        // --- 4+5. Simulation (no trade log, no trace buffers) ---
        // In normal mode (resample_to set), simulate on resampled bars.
        // This avoids hybrid overhead (iterating over fine bars when signals
        // only change at coarse boundaries).
        let sim_bars: &HashMap<SymbolId, RecordBatch> = if let Some(ref coarse) = coarse_bars {
            coarse
        } else {
            &aligned.symbol_bars
        };
        // Build coarse timestamps: last native timestamp of each resample group
        let coarse_timestamps: Vec<i64> = if let Some(ref groups) = resample_groups {
            groups
                .iter()
                .map(|(_, end)| master_timestamps[(*end).min(master_timestamps.len()) - 1])
                .collect()
        } else {
            Vec::new()
        };
        let sim_timestamps: &[i64] = if coarse_bars.is_some() {
            &coarse_timestamps
        } else {
            master_timestamps
        };
        let sim_num_bars = sim_timestamps.len();
        let sim_native_ns = if coarse_bars.is_some() {
            signal_ns
        } else {
            native_ns
        };

        let t_sim = std::time::Instant::now();
        let bar_seconds = sim_native_ns as f64 / 1_000_000_000.0;
        let delay = usize::try_from(config.execution.signal_delay)
            .map_err(|_| BtError::Strategy("signal_delay conversion overflow".to_string()))?;
        let is_pyramiding = config.execution.pyramiding;

        let n_sym = universe_sorted.len();

        let close_slices: Vec<&[f64]> = universe_sorted
            .iter()
            .map(|s| {
                let batch = &sim_bars[s];
                let col = batch
                    .column_by_name("close")
                    .unwrap()
                    .as_any()
                    .downcast_ref::<Float64Array>()
                    .unwrap();
                col.values().as_ref()
            })
            .collect();
        let target_slices: Vec<&[f64]> = universe_sorted
            .iter()
            .map(|s| symbol_targets[s].values().as_ref())
            .collect();
        let target_nulls: Vec<_> = universe_sorted
            .iter()
            .map(|s| symbol_targets[s].nulls())
            .collect();

        // BarsViews for fill price resolution.
        let bars_views: Vec<BarsView<'_>> = universe_sorted
            .iter()
            .map(|s| BarsView::from_batch(&sim_bars[s]).unwrap())
            .collect();

        // Resolve funding rates (mirrors full run).
        let mut funding_arrays: Vec<Option<Float64Array>> = Vec::with_capacity(n_sym);
        let mut lite_warnings = Vec::new();
        for &symbol in universe_sorted.iter() {
            let batch = &sim_bars[&symbol];
            let fr = resolve_funding_rates(batch, config, &mut lite_warnings)?;
            funding_arrays.push(fr);
        }
        let funding_slices: Vec<Option<&[f64]>> = funding_arrays
            .iter()
            .map(|opt| opt.as_ref().map(|f| f.values().as_ref()))
            .collect();
        let funding_nulls: Vec<_> = funding_arrays
            .iter()
            .map(|opt| opt.as_ref().and_then(|f| f.nulls()))
            .collect();

        let use_fast_path = can_use_fast_path(config);
        let sim_lite = if use_fast_path {
            tracing::debug!("lite: using fast-path simulation kernel");
            simulate_fast_lite(
                sim_num_bars,
                n_sym,
                &close_slices,
                &target_slices,
                &target_nulls,
                sim_timestamps,
                config,
                delay,
            )?
        } else {
            let fill_model = &config.execution.fill_model;

            let mut capital = config.initial_capital;
            let mut positions: Vec<f64> = vec![0.0; n_sym];
            let mut pending: Vec<Option<PendingOrder>> = vec![None; n_sym];
            let mut bar_closes: Vec<f64> = vec![f64::NAN; n_sym];
            let mut last_known_close: Vec<f64> = vec![f64::NAN; n_sym];
            let mut last_raw_target: Vec<f64> = vec![f64::NAN; n_sym];
            let has_exits_lite = config.execution.orders.has_exit_orders();
            let mut brackets_lite: Vec<Option<ActiveBracket>> = vec![None; n_sym];

            // Lite metrics state: daily equity + running drawdown.
            let nanos_per_day: i64 = 86_400_000_000_000;
            let mut daily_equities: Vec<f64> = Vec::with_capacity(1100);
            let mut daily_timestamps: Vec<i64> = Vec::with_capacity(1100);
            let mut current_day = i64::MIN;
            let mut peak_equity = config.initial_capital;
            let mut max_drawdown: f64 = 0.0;
            let mut trade_count: usize = 0;
            let mut last_equity = config.initial_capital;

            let mut warnings = Vec::new();

            for row in 0..sim_num_bars {
                for si in 0..n_sym {
                    let raw = close_slices[si][row];
                    if !raw.is_nan() {
                        bar_closes[si] = raw;
                        last_known_close[si] = raw;
                    } else {
                        bar_closes[si] = last_known_close[si];
                    }
                }

                let mut current_equity = capital;
                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if !c.is_nan() {
                        current_equity += positions[si] * c;
                    }
                }

                // --- Lite Phase B0: Update trailing stops ---
                if has_exits_lite {
                    if let Some(ref ts_cfg) = config.execution.orders.trailing_stop {
                        for si in 0..n_sym {
                            if let Some(ref mut bracket) = brackets_lite[si] {
                                let bv = &bars_views[si];
                                if !bv.high.is_null(row) && !bv.low.is_null(row) {
                                    let h = bv.high.value(row);
                                    let l = bv.low.value(row);
                                    bracket.update_trail(h, l, ts_cfg.trail_pct, ts_cfg.use_high);
                                }
                            }
                        }
                    }
                }

                // --- Lite Phase B1: Check SL / TP triggers ---
                if has_exits_lite {
                    for si in 0..n_sym {
                        if positions[si].abs() < 1e-12 {
                            continue;
                        }
                        let Some(ref bracket) = brackets_lite[si] else {
                            continue;
                        };
                        let bv = &bars_views[si];
                        if bv.high.is_null(row) || bv.low.is_null(row) {
                            continue;
                        }
                        let bar_high = bv.high.value(row);
                        let bar_low = bv.low.value(row);

                        let stop_hit = bracket.check_stop(bar_high, bar_low);
                        let tp_hit = bracket.check_tp(bar_high, bar_low);

                        if let Some((trigger_price, _exit_reason)) = stop_hit {
                            let exit_qty = positions[si].abs();
                            let side = if positions[si] > 0.0 {
                                Side::Sell
                            } else {
                                Side::Buy
                            };
                            let bar = bar_data(bv, row)?;
                            let fill_price = config.slippage.adjusted_price(
                                trigger_price,
                                exit_qty,
                                side,
                                &bar,
                                &mut warnings,
                            );
                            let fill_price = fill_price.clamp(bar_low, bar_high);
                            let fees =
                                compute_fees(exit_qty, fill_price, &config.fees, FillType::Taker);

                            let signed_fill = if side == Side::Buy {
                                exit_qty
                            } else {
                                -exit_qty
                            };
                            capital -= signed_fill * fill_price + fees;
                            positions[si] += signed_fill;
                            trade_count += 1;

                            brackets_lite[si] = None;
                            pending[si] = None;
                            last_raw_target[si] = f64::NAN;
                        } else if let Some(tp_price) = tp_hit {
                            let exit_qty = positions[si].abs();
                            let side = if positions[si] > 0.0 {
                                Side::Sell
                            } else {
                                Side::Buy
                            };
                            let fees =
                                compute_fees(exit_qty, tp_price, &config.fees, FillType::Maker);
                            let signed_fill = if side == Side::Buy {
                                exit_qty
                            } else {
                                -exit_qty
                            };
                            capital -= signed_fill * tp_price + fees;
                            positions[si] += signed_fill;
                            trade_count += 1;

                            brackets_lite[si] = None;
                            pending[si] = None;
                            last_raw_target[si] = f64::NAN;
                        }
                    }
                }

                // Signal delay — create new pending orders.
                let warmup = config.warmup_bars as usize;
                for si in 0..n_sym {
                    if row < delay + warmup {
                        continue;
                    }
                    if pending[si].is_some() {
                        let signal_row = row - delay;
                        let is_null = target_nulls[si]
                            .as_ref()
                            .is_some_and(|nb| nb.is_null(signal_row));
                        if !is_null {
                            let raw_target = target_slices[si][signal_row];
                            if !raw_target.is_nan()
                                && (raw_target - last_raw_target[si]).abs() >= 1e-12
                            {
                                // New signal differs — override below.
                            } else {
                                continue;
                            }
                        } else {
                            continue;
                        }
                    }
                    let signal_row = row - delay;
                    let is_null = target_nulls[si]
                        .as_ref()
                        .is_some_and(|nb| nb.is_null(signal_row));
                    if is_null {
                        continue;
                    }
                    let raw_target = target_slices[si][signal_row];
                    if raw_target.is_nan() {
                        continue;
                    }
                    if !is_pyramiding
                        && !last_raw_target[si].is_nan()
                        && (raw_target - last_raw_target[si]).abs() < 1e-12
                    {
                        continue;
                    }
                    last_raw_target[si] = raw_target;
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let current_pos = positions[si];
                    let desired = sanitize_target(
                        raw_target,
                        &config.execution,
                        c,
                        current_equity,
                        config.initial_capital,
                        current_pos,
                    );
                    let delta = desired - current_pos;
                    if delta.abs() > 1e-12 {
                        let (lp, tif) = if let Some(ref lim) = config.execution.orders.limit_entry {
                            let dir = if delta > 0.0 { -1.0 } else { 1.0 };
                            let price = c * (1.0 + dir * lim.offset_bps / 10_000.0);
                            (Some(price), lim.time_in_force.clone())
                        } else {
                            (None, TimeInForce::GTC)
                        };
                        if has_exits_lite {
                            brackets_lite[si] = None;
                        }
                        pending[si] = Some(PendingOrder {
                            signal_row,
                            remaining_delta: delta,
                            fill_price_x_qty: 0.0,
                            total_filled: 0.0,
                            total_fees: 0.0,
                            total_slippage: 0.0,
                            limit_price: lp,
                            bars_alive: 0,
                            time_in_force: tif,
                            is_buy: delta > 0.0,
                        });
                    }
                }

                // Execute trades (no logging, just count).
                for si in 0..n_sym {
                    let Some(ref mut order) = pending[si] else {
                        continue;
                    };
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let bv = &bars_views[si];
                    if config.execution.skip_gap_bars && bv.is_gap_at(row) {
                        // skip
                    } else {
                        // --- Limit order gate (lite) ---
                        let is_limit = order.limit_price.is_some();
                        #[allow(unused_assignments)]
                        let mut limit_can_fill = true;
                        if let Some(limit) = order.limit_price {
                            let h = bv.high.value(row);
                            let l = bv.low.value(row);
                            limit_can_fill = if order.is_buy { l <= limit } else { h >= limit };
                            if !limit_can_fill {
                                order.bars_alive += 1;
                                match &order.time_in_force {
                                    TimeInForce::IOC => {
                                        pending[si] = None;
                                        continue;
                                    }
                                    TimeInForce::GTB(n) => {
                                        if order.bars_alive >= *n {
                                            pending[si] = None;
                                            continue;
                                        }
                                    }
                                    TimeInForce::GTC => {}
                                }
                                continue;
                            }
                        }

                        let bar = bar_data(bv, row)?;

                        let intended = if is_limit {
                            order.limit_price.unwrap()
                        } else {
                            let base = resolve_execution_price(
                                bv,
                                row,
                                &config.execution.execution_price,
                                &mut warnings,
                            )?;
                            approximate_intra_bar_price(bv, row, &fill_model.intra_bar_price, base)
                        };

                        let abs_remaining = order.remaining_delta.abs();
                        let fill_qty = if fill_model.max_participation_rate > 0.0 {
                            let max_qty = bar.volume * fill_model.max_participation_rate;
                            abs_remaining.min(max_qty)
                        } else {
                            abs_remaining
                        };

                        if fill_qty > 1e-15 {
                            let side = if order.is_buy { Side::Buy } else { Side::Sell };

                            let (fill_price, fill_type) = if is_limit {
                                (intended, FillType::Maker)
                            } else {
                                let fp = config.slippage.adjusted_price(
                                    intended,
                                    fill_qty,
                                    side,
                                    &bar,
                                    &mut warnings,
                                );
                                (fp, config.fees.default_fill_type)
                            };

                            let fees = compute_fees(fill_qty, fill_price, &config.fees, fill_type);

                            let signed_fill = if side == Side::Buy {
                                fill_qty
                            } else {
                                -fill_qty
                            };
                            capital -= signed_fill * fill_price + fees;
                            positions[si] += signed_fill;
                            order.remaining_delta -= signed_fill;
                            order.fill_price_x_qty += fill_price * fill_qty;
                            order.total_filled += fill_qty;
                            order.total_fees += fees;
                        }

                        if order.remaining_delta.abs() < 1e-12 || fill_qty < 1e-15 {
                            if order.total_filled > 1e-15 {
                                trade_count += 1;
                                // Create bracket for new positions (lite).
                                if has_exits_lite && positions[si].abs() > 1e-12 {
                                    let avg_fill =
                                        order.fill_price_x_qty / order.total_filled.max(1e-15);
                                    brackets_lite[si] = ActiveBracket::new(
                                        avg_fill,
                                        positions[si],
                                        &config.execution.orders,
                                    );
                                } else if positions[si].abs() < 1e-12 {
                                    brackets_lite[si] = None;
                                }
                            }
                            pending[si] = None;
                        }
                    }
                }

                // Funding + borrow costs (matches full run).
                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if c.is_nan() {
                        continue;
                    }
                    let pos = positions[si];
                    if let Some(fr) = funding_slices[si] {
                        let is_null = funding_nulls[si].as_ref().is_some_and(|nb| nb.is_null(row));
                        if !is_null {
                            capital += funding_pnl(pos, c, fr[row]);
                        }
                    }
                    capital -= borrow_cost(pos, c, config.fees.borrow_rate_annual_bps, bar_seconds);
                }

                // Equity after trades.
                let mut equity = capital;
                for si in 0..n_sym {
                    let c = bar_closes[si];
                    if !c.is_nan() {
                        equity += positions[si] * c;
                    }
                }

                last_equity = equity;

                // Running max drawdown.
                if equity > peak_equity {
                    peak_equity = equity;
                }
                let dd = (peak_equity - equity) / peak_equity;
                if dd > max_drawdown {
                    max_drawdown = dd;
                }

                // Daily equity tracking (last value per UTC day).
                let ts = master_timestamps[row];
                let day = ts / nanos_per_day;
                if day != current_day {
                    if current_day != i64::MIN {
                        // Previous bar was end of previous day — already pushed below.
                    }
                    current_day = day;
                }
                // Always overwrite with latest equity for this day.
                if daily_timestamps.last().copied() == Some(ts / nanos_per_day * nanos_per_day) {
                    *daily_equities.last_mut().unwrap() = equity;
                } else if daily_timestamps.is_empty()
                    || *daily_timestamps.last().unwrap() / nanos_per_day != day
                {
                    daily_equities.push(equity);
                    daily_timestamps.push(ts);
                } else {
                    *daily_equities.last_mut().unwrap() = equity;
                }
            }

            SimLiteResult {
                trade_count,
                last_equity,
                peak_equity,
                max_drawdown,
                daily_equities,
                daily_timestamps,
                warnings,
            }
        }; // end if/else fast_path

        let SimLiteResult {
            trade_count,
            last_equity,
            max_drawdown,
            mut daily_equities,
            mut daily_timestamps,
            ..
        } = sim_lite;

        // Ensure last bar is captured.
        let nanos_per_day: i64 = 86_400_000_000_000;
        if let Some(last_ts) = master_timestamps.last() {
            let day = last_ts / nanos_per_day;
            if daily_timestamps.is_empty()
                || *daily_timestamps.last().unwrap() / nanos_per_day != day
            {
                daily_equities.push(last_equity);
                daily_timestamps.push(*last_ts);
            } else {
                *daily_equities.last_mut().unwrap() = last_equity;
            }
        }

        // Compute metrics from daily equity.
        let mut metrics = compute_performance_metrics_with_rf(
            &daily_equities,
            &daily_timestamps,
            86400.0, // daily resolution
            config.trading_days_per_year,
            config.risk_free_rate,
        )?;

        // CAPM alpha (lite path): use close_slices already extracted above.
        {
            let close_refs: Vec<&[f64]> = close_slices.to_vec();
            let benchmark_rets = compute_benchmark_daily_returns(&close_refs, master_timestamps);
            let strategy_rets: Vec<f64> = daily_equities
                .windows(2)
                .map(|w| if w[0] != 0.0 { w[1] / w[0] - 1.0 } else { 0.0 })
                .collect();
            let ols = compute_capm_alpha(
                &strategy_rets,
                &benchmark_rets,
                config.trading_days_per_year,
            );
            metrics.alpha = ols.alpha;
            metrics.beta = ols.beta;
            metrics.tstat_alpha = ols.tstat_alpha;
        }

        // Override max_drawdown with full-res running value.
        let mut metrics = metrics;
        metrics.max_drawdown = max_drawdown;

        profile.simulation_us = t_sim.elapsed().as_micros() as u64;

        Ok(BatchResultLite {
            strategy_name: strategy.name.clone(),
            metrics,
            final_equity: last_equity,
            trade_count,
            profile,
        })
    }
}

impl BacktestRunner for SimpleBacktestRunner {
    #[tracing::instrument(level = "info", skip_all)]
    fn run(
        &self,
        strategy: &StrategyPlan,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
    ) -> Result<BacktestResult> {
        let t_total = std::time::Instant::now();
        tracing::info!("backtest run started");

        let (aligned, universe_sorted, version, data_load_us, align_us) =
            Self::load_and_align(config, data_store)?;

        let mut result =
            self.run_on_aligned(strategy, config, &aligned, &universe_sorted, &version)?;
        result.profile.data_load_us = data_load_us;
        result.profile.align_us = align_us;
        result.profile.total_us = t_total.elapsed().as_micros() as u64;

        tracing::info!(
            total_ms = result.profile.total_us,
            trades = result.trades.num_rows(),
            final_equity = result
                .equity_curve
                .as_any()
                .downcast_ref::<Float64Array>()
                .and_then(|a| a.values().last().copied())
                .unwrap_or(0.0),
            "backtest complete"
        );

        Ok(result)
    }

    #[tracing::instrument(level = "info", skip_all)]
    fn run_sweep(
        &self,
        strategy: &StrategyPlan,
        param_grid: &ParamGrid,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BacktestResult>> {
        if max_parallelism == 0 {
            return Err(BtError::Strategy(
                "max_parallelism must be greater than zero".to_string(),
            ));
        }

        let combos = expand_param_grid(param_grid);
        tracing::info!(combos = combos.len(), "starting parameter sweep");

        // Load bars ONCE, share across all combos.
        let (aligned, universe_sorted, version, data_load_us, align_us) =
            Self::load_and_align(config, data_store)?;

        let pool = ThreadPoolBuilder::new()
            .num_threads(max_parallelism)
            .build()
            .map_err(|err| {
                BtError::Strategy(format!("failed to build sweep thread pool: {err}"))
            })?;

        let mut indexed = pool.install(|| {
            combos
                .into_par_iter()
                .enumerate()
                .map(|(index, params)| {
                    let variant = strategy_with_params(strategy, &params)?;
                    let evaluator = DefaultExprEvaluator::new();
                    let runner = SimpleBacktestRunner {
                        evaluator: Box::new(evaluator),
                    };
                    runner
                        .run_on_aligned(&variant, config, &aligned, &universe_sorted, &version)
                        .map(|result| (index, result))
                })
                .collect::<Vec<_>>()
        });

        let mut results = Vec::with_capacity(indexed.len());
        indexed.sort_by_key(|entry| match entry {
            Ok((index, _)) => *index,
            Err(_) => usize::MAX,
        });

        for entry in indexed {
            let (_, mut result) = entry?;
            result.profile.data_load_us = data_load_us;
            result.profile.align_us = align_us;
            results.push(result);
        }

        Ok(results)
    }

    #[tracing::instrument(level = "info", skip_all)]
    fn run_sweep_lite(
        &self,
        strategy: &StrategyPlan,
        param_grid: &ParamGrid,
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BatchResultLite>> {
        let t_total = std::time::Instant::now();
        let threads = if max_parallelism == 0 {
            10
        } else {
            max_parallelism
        };

        let combos = expand_param_grid(param_grid);
        tracing::info!(combos = combos.len(), threads, "starting sweep lite");

        let (mut aligned, universe_sorted, _version, data_load_us, align_us) =
            Self::load_and_align(config, data_store)?;

        // Pre-resample once if bar_interval > native resolution.
        // This avoids redundant resample_batch calls inside each combo.
        let native_ns = detect_native_interval_ns(&aligned.master_timestamps);
        let signal_interval = config.resample_to.as_ref().unwrap_or(&config.bar_interval);
        let signal_ns = signal_interval.as_nanos();
        if signal_ns > native_ns {
            let groups = compute_resample_groups(&aligned.master_timestamps, signal_ns);
            let coarse_len = groups.len();
            let coarse_bars: HashMap<SymbolId, RecordBatch> = aligned
                .symbol_bars
                .par_iter()
                .map(|(&symbol, batch)| {
                    resample_batch(batch, &groups, coarse_len).map(|rb| (symbol, rb))
                })
                .collect::<Result<_>>()?;
            let coarse_timestamps: Vec<i64> = groups
                .iter()
                .map(|(_, end)| {
                    aligned.master_timestamps[(*end).min(aligned.master_timestamps.len()) - 1]
                })
                .collect();
            aligned.symbol_bars = coarse_bars;
            aligned.master_timestamps = coarse_timestamps;
            tracing::info!(
                coarse_bars = coarse_len,
                "sweep lite: pre-resampled to signal interval"
            );
        }

        let cache = Arc::new(IndicatorCache::new());

        let pool = ThreadPoolBuilder::new()
            .num_threads(threads)
            .build()
            .map_err(|err| {
                BtError::Strategy(format!("failed to build sweep thread pool: {err}"))
            })?;

        let t_sim = std::time::Instant::now();
        let mut indexed = pool.install(|| {
            combos
                .into_par_iter()
                .enumerate()
                .map(|(index, params)| {
                    let variant = strategy_with_params(strategy, &params)?;
                    let evaluator = DefaultExprEvaluator::with_cache(cache.clone());
                    let runner = SimpleBacktestRunner {
                        evaluator: Box::new(evaluator),
                    };
                    runner
                        .run_lite_on_aligned(&variant, config, &aligned, &universe_sorted)
                        .map(|result| (index, result))
                })
                .collect::<Vec<_>>()
        });
        tracing::info!(
            elapsed_ms = t_sim.elapsed().as_millis() as u64,
            combos = indexed.len(),
            "sweep lite: simulation complete"
        );

        indexed.sort_by_key(|entry| match entry {
            Ok((index, _)) => *index,
            Err(_) => usize::MAX,
        });

        let mut results = Vec::with_capacity(indexed.len());
        for entry in indexed {
            let (_, mut result) = entry?;
            result.profile.data_load_us = data_load_us;
            result.profile.align_us = align_us;
            result.profile.total_us = t_total.elapsed().as_micros() as u64;
            results.push(result);
        }

        Ok(results)
    }

    #[tracing::instrument(level = "info", skip_all)]
    fn run_batch(
        &self,
        strategies: &[StrategyPlan],
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BacktestResult>> {
        let threads = if max_parallelism == 0 {
            10
        } else {
            max_parallelism
        };

        tracing::info!(strategies = strategies.len(), threads, "starting batch run");

        // Load bars ONCE, share across all strategies.
        let (aligned, universe_sorted, version, data_load_us, align_us) =
            Self::load_and_align(config, data_store)?;

        let pool = ThreadPoolBuilder::new()
            .num_threads(threads)
            .build()
            .map_err(|err| {
                BtError::Strategy(format!("failed to build batch thread pool: {err}"))
            })?;

        let mut indexed = pool.install(|| {
            strategies
                .par_iter()
                .enumerate()
                .map(|(index, strategy)| {
                    let evaluator = DefaultExprEvaluator::new();
                    let runner = SimpleBacktestRunner {
                        evaluator: Box::new(evaluator),
                    };
                    runner
                        .run_on_aligned(strategy, config, &aligned, &universe_sorted, &version)
                        .map(|result| (index, result))
                })
                .collect::<Vec<_>>()
        });

        indexed.sort_by_key(|entry| match entry {
            Ok((index, _)) => *index,
            Err(_) => usize::MAX,
        });

        let mut results = Vec::with_capacity(indexed.len());
        for entry in indexed {
            let (_, mut result) = entry?;
            result.profile.data_load_us = data_load_us;
            result.profile.align_us = align_us;
            results.push(result);
        }

        tracing::info!(strategies = strategies.len(), "batch run complete");
        Ok(results)
    }

    #[tracing::instrument(level = "info", skip_all)]
    fn run_batch_lite(
        &self,
        strategies: &[StrategyPlan],
        config: &BacktestConfig,
        data_store: &dyn DataStore,
        max_parallelism: usize,
    ) -> Result<Vec<BatchResultLite>> {
        let t_total = std::time::Instant::now();
        let threads = if max_parallelism == 0 {
            10
        } else {
            max_parallelism
        };

        tracing::info!(
            strategies = strategies.len(),
            threads,
            "starting batch lite"
        );

        let (mut aligned, universe_sorted, _version, data_load_us, align_us) =
            Self::load_and_align(config, data_store)?;

        // Pre-resample once if bar_interval > native resolution.
        let native_ns = detect_native_interval_ns(&aligned.master_timestamps);
        let signal_interval = config.resample_to.as_ref().unwrap_or(&config.bar_interval);
        let signal_ns = signal_interval.as_nanos();
        if signal_ns > native_ns {
            let groups = compute_resample_groups(&aligned.master_timestamps, signal_ns);
            let coarse_len = groups.len();
            let coarse_bars: HashMap<SymbolId, RecordBatch> = aligned
                .symbol_bars
                .par_iter()
                .map(|(&symbol, batch)| {
                    resample_batch(batch, &groups, coarse_len).map(|rb| (symbol, rb))
                })
                .collect::<Result<_>>()?;
            let coarse_timestamps: Vec<i64> = groups
                .iter()
                .map(|(_, end)| {
                    aligned.master_timestamps[(*end).min(aligned.master_timestamps.len()) - 1]
                })
                .collect();
            aligned.symbol_bars = coarse_bars;
            aligned.master_timestamps = coarse_timestamps;
        }

        let pool = ThreadPoolBuilder::new()
            .num_threads(threads)
            .build()
            .map_err(|err| {
                BtError::Strategy(format!("failed to build batch thread pool: {err}"))
            })?;

        // Shared indicator cache: EMA(12) computed once, reused by all strategies
        let cache = Arc::new(IndicatorCache::new());

        let t_sim = std::time::Instant::now();
        let mut indexed = pool.install(|| {
            strategies
                .par_iter()
                .enumerate()
                .map(|(index, strategy)| {
                    let evaluator = DefaultExprEvaluator::with_cache(cache.clone());
                    let runner = SimpleBacktestRunner {
                        evaluator: Box::new(evaluator),
                    };
                    runner
                        .run_lite_on_aligned(strategy, config, &aligned, &universe_sorted)
                        .map(|result| (index, result))
                })
                .collect::<Vec<_>>()
        });
        tracing::info!(
            elapsed_ms = t_sim.elapsed().as_millis() as u64,
            strategies = strategies.len(),
            "batch lite: simulation complete"
        );

        indexed.sort_by_key(|entry| match entry {
            Ok((index, _)) => *index,
            Err(_) => usize::MAX,
        });

        let mut results = Vec::with_capacity(indexed.len());
        for entry in indexed {
            let (_, mut result) = entry?;
            result.profile.data_load_us = data_load_us;
            result.profile.align_us = align_us;
            result.profile.total_us = t_total.elapsed().as_micros() as u64;
            results.push(result);
        }

        tracing::info!(
            total_ms = t_total.elapsed().as_millis() as u64,
            strategies = strategies.len(),
            "batch lite complete"
        );
        Ok(results)
    }
}

fn batch_as_env(batch: &RecordBatch) -> HashMap<String, ArrayRef> {
    batch
        .schema()
        .fields()
        .iter()
        .enumerate()
        .map(|(idx, field)| (field.name().to_string(), batch.column(idx).clone()))
        .collect()
}

fn resolve_funding_rates(
    bars: &RecordBatch,
    config: &BacktestConfig,
    warnings: &mut Vec<String>,
) -> Result<Option<Float64Array>> {
    let Some(column) = config.fees.funding_rate_column.as_ref() else {
        return Ok(None);
    };

    let Some(array) = bars.column_by_name(column) else {
        warnings.push(format!(
            "funding rate column '{column}' is missing; funding pnl disabled"
        ));
        return Ok(None);
    };

    let rates = array
        .as_any()
        .downcast_ref::<Float64Array>()
        .ok_or_else(|| BtError::Data(format!("funding rate column '{column}' must be Float64")))?;
    Ok(Some(rates.clone()))
}

/// Returns `true` when the config qualifies for the stripped-down fast-path
/// simulation kernel (market-at-close, no SL/TP, no partial fills, etc.).
fn can_use_fast_path(config: &BacktestConfig) -> bool {
    let exec = &config.execution;
    exec.execution_price == ExecutionPrice::AtClose
        && !exec.pyramiding
        && exec.fill_model.max_participation_rate == 0.0
        && exec.fill_model.intra_bar_price == IntraBarPrice::SinglePoint
        && exec.orders.is_empty()
        && matches!(config.slippage, SlippageConfig::FixedBps { .. })
        && config.fees.funding_rate_column.is_none()
        && config.fees.borrow_rate_annual_bps == 0.0
        && !exec.skip_gap_bars
}

/// Result bundle from the full simulation loop (fast or general kernel).
struct SimResult {
    trace_positions: Vec<Vec<f64>>,
    trace_closes: Vec<Vec<f64>>,
    trace_capitals: Vec<f64>,
    trace_equities: Vec<f64>,
    trade_signal_ts: Vec<i64>,
    trade_exec_ts: Vec<i64>,
    trade_symbol_ids: Vec<u32>,
    trade_sides: Vec<u8>,
    trade_qty: Vec<f64>,
    trade_intended: Vec<f64>,
    trade_fill: Vec<f64>,
    trade_fees: Vec<f64>,
    trade_slippage: Vec<f64>,
    trade_gap: Vec<bool>,
    trade_exit_reasons: Vec<u8>,
    warnings: Vec<String>,
}

#[allow(dead_code)]
/// Result bundle from the lite simulation loop.
struct SimLiteResult {
    trade_count: usize,
    last_equity: f64,
    peak_equity: f64,
    max_drawdown: f64,
    daily_equities: Vec<f64>,
    daily_timestamps: Vec<i64>,
    warnings: Vec<String>,
}

/// Fast-path simulation kernel: market-at-close, no SL/TP, no partial fills.
/// Produces bit-identical results to the general loop for qualifying configs.
#[allow(clippy::too_many_arguments)]
fn simulate_fast(
    num_bars: usize,
    n_sym: usize,
    close_slices: &[&[f64]],
    target_slices: &[&[f64]],
    target_nulls: &[Option<&arrow::buffer::NullBuffer>],
    bars_views: &[&BarsView<'_>],
    universe_sorted: &[SymbolId],
    config: &BacktestConfig,
    delay: usize,
) -> Result<SimResult> {
    // Pre-extract constants.
    let slippage_bps = match &config.slippage {
        SlippageConfig::FixedBps { bps } => *bps,
        _ => unreachable!(),
    };
    let slip_buy = 1.0 + slippage_bps / 10_000.0;
    let slip_sell = 1.0 - slippage_bps / 10_000.0;
    let no_slip = slippage_bps == 0.0;
    let fee_rate = match config.fees.default_fill_type {
        FillType::Maker => config.fees.maker_fee_bps / 10_000.0,
        FillType::Taker => config.fees.taker_fee_bps / 10_000.0,
    };
    let min_fee = config.fees.min_fee;
    let skip_rows = delay + config.warmup_bars as usize;
    let sizing_mode = &config.execution.position_sizing_mode;
    let allow_frac = config.execution.allow_fractional;
    let allow_short = config.execution.allow_short;
    let max_pos_pct = config.execution.max_position_pct;
    let init_cap = config.initial_capital;

    let mut capital = config.initial_capital;
    let mut positions: Vec<f64> = vec![0.0; n_sym];
    let mut bar_closes: Vec<f64> = vec![f64::NAN; n_sym];
    let mut last_known_close: Vec<f64> = vec![f64::NAN; n_sym];
    let mut last_raw_target: Vec<f64> = vec![f64::NAN; n_sym];

    let mut trace_positions: Vec<Vec<f64>> =
        (0..n_sym).map(|_| Vec::with_capacity(num_bars)).collect();
    let mut trace_closes: Vec<Vec<f64>> =
        (0..n_sym).map(|_| Vec::with_capacity(num_bars)).collect();
    let mut trace_capitals: Vec<f64> = Vec::with_capacity(num_bars);
    let mut trace_equities: Vec<f64> = Vec::with_capacity(num_bars);

    let mut trade_signal_ts = Vec::new();
    let mut trade_exec_ts = Vec::new();
    let mut trade_symbol_ids = Vec::new();
    let mut trade_sides = Vec::new();
    let mut trade_qty = Vec::new();
    let mut trade_intended = Vec::new();
    let mut trade_fill = Vec::new();
    let mut trade_fees = Vec::new();
    let mut trade_slippage = Vec::new();
    let mut trade_gap = Vec::new();
    let mut trade_exit_reasons: Vec<u8> = Vec::new();

    for row in 0..num_bars {
        // Update closes (carry-forward for gap bars).
        for si in 0..n_sym {
            let raw = close_slices[si][row];
            if !raw.is_nan() {
                bar_closes[si] = raw;
                last_known_close[si] = raw;
            } else {
                bar_closes[si] = last_known_close[si];
            }
        }

        // Compute equity before any fills (used for all symbols this bar).
        let mut current_equity = capital;
        for si in 0..n_sym {
            let c = bar_closes[si];
            if !c.is_nan() {
                current_equity += positions[si] * c;
            }
        }

        // Signal → immediate atomic fill.
        if row >= skip_rows {
            let signal_row = row - delay;
            for si in 0..n_sym {
                if target_nulls[si].is_some_and(|nb| nb.is_null(signal_row)) {
                    continue;
                }
                let raw_target = target_slices[si][signal_row];
                if raw_target.is_nan() {
                    continue;
                }
                if !last_raw_target[si].is_nan() && (raw_target - last_raw_target[si]).abs() < 1e-12
                {
                    continue;
                }
                last_raw_target[si] = raw_target;

                let c = bar_closes[si];
                if c.is_nan() {
                    continue;
                }

                // Inline sanitize_target (no pyramiding).
                let units = match sizing_mode {
                    PositionSizingMode::FractionOfEquity => {
                        raw_target * current_equity / c.max(1e-9)
                    }
                    PositionSizingMode::FractionOfInitialCapital => {
                        raw_target * init_cap / c.max(1e-9)
                    }
                    PositionSizingMode::Units => raw_target,
                };
                let mut desired = units;
                if !allow_frac {
                    desired = desired.round();
                }
                if !allow_short {
                    desired = desired.max(0.0);
                }
                let max_units = (current_equity.abs() * max_pos_pct) / c.max(1e-9);
                desired = desired.clamp(-max_units, max_units);

                let delta = desired - positions[si];
                if delta.abs() <= 1e-12 {
                    continue;
                }

                // Inline fill: AtClose + FixedBps slippage.
                let is_buy = delta > 0.0;
                let fill_qty = delta.abs();
                let intended = c;
                let fill_price = if no_slip {
                    intended
                } else if is_buy {
                    intended * slip_buy
                } else {
                    intended * slip_sell
                };
                let notional = fill_qty * fill_price;
                let fee = (notional * fee_rate).max(min_fee);

                let signed_fill = if is_buy { fill_qty } else { -fill_qty };
                capital -= signed_fill * fill_price + fee;
                positions[si] += signed_fill;

                // Record trade.
                let bv = bars_views[si];
                trade_signal_ts.push(bv.timestamp_value(signal_row));
                trade_exec_ts.push(bv.timestamp_value(row));
                trade_symbol_ids.push(universe_sorted[si].0);
                trade_sides.push(if is_buy { 1_u8 } else { 2_u8 });
                trade_qty.push(fill_qty);
                trade_intended.push(intended);
                trade_fill.push(fill_price);
                trade_fees.push(fee);
                trade_slippage.push((fill_price - intended).abs());
                trade_gap.push(bv.is_gap_at(row));
                trade_exit_reasons.push(ExitReason::Signal.as_u8());
            }
        }

        // No funding/borrow loop.

        // Equity after trades.
        let mut equity = capital;
        for si in 0..n_sym {
            let c = bar_closes[si];
            if !c.is_nan() {
                equity += positions[si] * c;
            }
        }
        for si in 0..n_sym {
            trace_positions[si].push(positions[si]);
            trace_closes[si].push(bar_closes[si]);
        }
        trace_capitals.push(capital);
        trace_equities.push(equity);
    }

    Ok(SimResult {
        trace_positions,
        trace_closes,
        trace_capitals,
        trace_equities,
        trade_signal_ts,
        trade_exec_ts,
        trade_symbol_ids,
        trade_sides,
        trade_qty,
        trade_intended,
        trade_fill,
        trade_fees,
        trade_slippage,
        trade_gap,
        trade_exit_reasons,
        warnings: Vec::new(),
    })
}

/// Fast-path lite kernel: same as `simulate_fast` but only tracks metrics.
#[allow(clippy::too_many_arguments)]
fn simulate_fast_lite(
    num_bars: usize,
    n_sym: usize,
    close_slices: &[&[f64]],
    target_slices: &[&[f64]],
    target_nulls: &[Option<&arrow::buffer::NullBuffer>],
    master_timestamps: &[i64],
    config: &BacktestConfig,
    delay: usize,
) -> Result<SimLiteResult> {
    let slippage_bps = match &config.slippage {
        SlippageConfig::FixedBps { bps } => *bps,
        _ => unreachable!(),
    };
    let slip_buy = 1.0 + slippage_bps / 10_000.0;
    let slip_sell = 1.0 - slippage_bps / 10_000.0;
    let no_slip = slippage_bps == 0.0;
    let fee_rate = match config.fees.default_fill_type {
        FillType::Maker => config.fees.maker_fee_bps / 10_000.0,
        FillType::Taker => config.fees.taker_fee_bps / 10_000.0,
    };
    let min_fee = config.fees.min_fee;
    let skip_rows = delay + config.warmup_bars as usize;
    let sizing_mode = &config.execution.position_sizing_mode;
    let allow_frac = config.execution.allow_fractional;
    let allow_short = config.execution.allow_short;
    let max_pos_pct = config.execution.max_position_pct;
    let init_cap = config.initial_capital;

    let mut capital = config.initial_capital;
    let mut positions: Vec<f64> = vec![0.0; n_sym];
    let mut bar_closes: Vec<f64> = vec![f64::NAN; n_sym];
    let mut last_known_close: Vec<f64> = vec![f64::NAN; n_sym];
    let mut last_raw_target: Vec<f64> = vec![f64::NAN; n_sym];

    let nanos_per_day: i64 = 86_400_000_000_000;
    let mut daily_equities: Vec<f64> = Vec::with_capacity(1100);
    let mut daily_timestamps: Vec<i64> = Vec::with_capacity(1100);
    let mut current_day = i64::MIN;
    let mut peak_equity = config.initial_capital;
    let mut max_drawdown: f64 = 0.0;
    let mut trade_count: usize = 0;
    let mut last_equity = config.initial_capital;

    for row in 0..num_bars {
        for si in 0..n_sym {
            let raw = close_slices[si][row];
            if !raw.is_nan() {
                bar_closes[si] = raw;
                last_known_close[si] = raw;
            } else {
                bar_closes[si] = last_known_close[si];
            }
        }

        let mut current_equity = capital;
        for si in 0..n_sym {
            let c = bar_closes[si];
            if !c.is_nan() {
                current_equity += positions[si] * c;
            }
        }

        if row >= skip_rows {
            let signal_row = row - delay;
            for si in 0..n_sym {
                if target_nulls[si].is_some_and(|nb| nb.is_null(signal_row)) {
                    continue;
                }
                let raw_target = target_slices[si][signal_row];
                if raw_target.is_nan() {
                    continue;
                }
                if !last_raw_target[si].is_nan() && (raw_target - last_raw_target[si]).abs() < 1e-12
                {
                    continue;
                }
                last_raw_target[si] = raw_target;

                let c = bar_closes[si];
                if c.is_nan() {
                    continue;
                }

                let units = match sizing_mode {
                    PositionSizingMode::FractionOfEquity => {
                        raw_target * current_equity / c.max(1e-9)
                    }
                    PositionSizingMode::FractionOfInitialCapital => {
                        raw_target * init_cap / c.max(1e-9)
                    }
                    PositionSizingMode::Units => raw_target,
                };
                let mut desired = units;
                if !allow_frac {
                    desired = desired.round();
                }
                if !allow_short {
                    desired = desired.max(0.0);
                }
                let max_units = (current_equity.abs() * max_pos_pct) / c.max(1e-9);
                desired = desired.clamp(-max_units, max_units);

                let delta = desired - positions[si];
                if delta.abs() <= 1e-12 {
                    continue;
                }

                let is_buy = delta > 0.0;
                let fill_qty = delta.abs();
                let intended = c;
                let fill_price = if no_slip {
                    intended
                } else if is_buy {
                    intended * slip_buy
                } else {
                    intended * slip_sell
                };
                let notional = fill_qty * fill_price;
                let fee = (notional * fee_rate).max(min_fee);

                let signed_fill = if is_buy { fill_qty } else { -fill_qty };
                capital -= signed_fill * fill_price + fee;
                positions[si] += signed_fill;
                trade_count += 1;
            }
        }

        // Equity after trades.
        let mut equity = capital;
        for si in 0..n_sym {
            let c = bar_closes[si];
            if !c.is_nan() {
                equity += positions[si] * c;
            }
        }

        last_equity = equity;
        if equity > peak_equity {
            peak_equity = equity;
        }
        let dd = (peak_equity - equity) / peak_equity;
        if dd > max_drawdown {
            max_drawdown = dd;
        }

        // Daily equity tracking (identical to general lite loop).
        let ts = master_timestamps[row];
        let day = ts / nanos_per_day;
        if day != current_day {
            current_day = day;
        }
        if daily_timestamps.last().copied() == Some(ts / nanos_per_day * nanos_per_day) {
            *daily_equities.last_mut().unwrap() = equity;
        } else if daily_timestamps.is_empty()
            || *daily_timestamps.last().unwrap() / nanos_per_day != day
        {
            daily_equities.push(equity);
            daily_timestamps.push(ts);
        } else {
            *daily_equities.last_mut().unwrap() = equity;
        }
    }

    Ok(SimLiteResult {
        trade_count,
        last_equity,
        peak_equity,
        max_drawdown,
        daily_equities,
        daily_timestamps,
        warnings: Vec::new(),
    })
}

fn sanitize_target(
    target: f64,
    execution: &ExecutionConfig,
    close: f64,
    equity: f64,
    initial_capital: f64,
    current_pos: f64,
) -> f64 {
    // Step 1: Convert signal to units based on the sizing mode.
    let units = match execution.position_sizing_mode {
        PositionSizingMode::FractionOfEquity => target * equity / close.max(1e-9),
        PositionSizingMode::FractionOfInitialCapital => target * initial_capital / close.max(1e-9),
        PositionSizingMode::Units => target,
    };

    // Step 2: If pyramiding, treat units as a delta to add to current position.
    // - signal == 0.0 (units ≈ 0) → go flat (target = 0, closes entire position)
    // - signal nonzero → accumulate: current_pos + units
    // - NaN/null → hold (handled upstream, never reaches here)
    let mut out = if execution.pyramiding {
        if units.abs() < 1e-15 {
            0.0
        } else {
            current_pos + units
        }
    } else {
        units
    };

    if !execution.allow_fractional {
        out = out.round();
    }
    if !execution.allow_short {
        out = out.max(0.0);
    }

    let max_units = (equity.abs() * execution.max_position_pct) / close.max(1e-9);
    out = out.clamp(-max_units, max_units);

    // In pyramiding mode, never let the cap force a trade opposite to the
    // signal direction.  Buy signal → only increase or hold; sell signal →
    // only decrease or hold.
    if execution.pyramiding {
        let delta = out - current_pos;
        if units > 1e-15 && delta < -1e-15 {
            out = current_pos; // buy signal but cap would sell → hold
        } else if units < -1e-15 && delta > 1e-15 {
            out = current_pos; // sell signal but would increase → hold
        }
    }

    out
}

/// Build positions batch: one row per (timestamp, symbol) pair.
fn build_positions_batch(
    master_timestamps: &[i64],
    universe: &[SymbolId],
    trace_positions: &[Vec<f64>],
    trace_closes: &[Vec<f64>],
    trace_capitals: &[f64],
    trace_equities: &[f64],
) -> Result<RecordBatch> {
    let schema = Arc::new(Schema::new(vec![
        Field::new(
            "timestamp",
            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
            false,
        ),
        Field::new("symbol_id", DataType::UInt32, false),
        Field::new("position", DataType::Float64, false),
        Field::new("close", DataType::Float64, false),
        Field::new("capital", DataType::Float64, false),
        Field::new("equity", DataType::Float64, false),
    ]));

    let num_bars = master_timestamps.len();
    let num_symbols = universe.len();
    let total_rows = num_bars * num_symbols;

    let mut timestamps = Vec::with_capacity(total_rows);
    let mut symbol_ids = Vec::with_capacity(total_rows);
    let mut positions = Vec::with_capacity(total_rows);
    let mut closes = Vec::with_capacity(total_rows);
    let mut capitals = Vec::with_capacity(total_rows);
    let mut equities = Vec::with_capacity(total_rows);

    for (row, &ts) in master_timestamps.iter().enumerate() {
        for (si, &symbol) in universe.iter().enumerate() {
            timestamps.push(ts);
            symbol_ids.push(symbol.0);
            positions.push(trace_positions[si][row]);
            closes.push(trace_closes[si][row]);
            capitals.push(trace_capitals[row]);
            equities.push(trace_equities[row]);
        }
    }

    RecordBatch::try_new(
        schema,
        vec![
            Arc::new(TimestampNanosecondArray::from(timestamps).with_timezone("UTC")),
            Arc::new(UInt32Array::from(symbol_ids)),
            Arc::new(Float64Array::from(positions)),
            Arc::new(Float64Array::from(closes)),
            Arc::new(Float64Array::from(capitals)),
            Arc::new(Float64Array::from(equities)),
        ],
    )
    .map_err(Into::into)
}

#[allow(clippy::too_many_arguments)]
fn build_trades_batch(
    signal_ts: Vec<i64>,
    execution_ts: Vec<i64>,
    symbol_ids: Vec<u32>,
    sides: Vec<u8>,
    quantity: Vec<f64>,
    intended_price: Vec<f64>,
    fill_price: Vec<f64>,
    fees: Vec<f64>,
    slippage: Vec<f64>,
    is_gap_bar: Vec<bool>,
    exit_reasons: Vec<u8>,
) -> Result<RecordBatch> {
    let schema = Arc::new(Schema::new(vec![
        Field::new(
            "signal_timestamp",
            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
            false,
        ),
        Field::new(
            "execution_timestamp",
            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
            false,
        ),
        Field::new("symbol_id", DataType::UInt32, false),
        Field::new("side", DataType::UInt8, false),
        Field::new("quantity", DataType::Float64, false),
        Field::new("intended_price", DataType::Float64, false),
        Field::new("fill_price", DataType::Float64, false),
        Field::new("fees", DataType::Float64, false),
        Field::new("slippage", DataType::Float64, false),
        Field::new("is_gap_bar", DataType::Boolean, false),
        Field::new("exit_reason", DataType::UInt8, false),
    ]));

    RecordBatch::try_new(
        schema,
        vec![
            Arc::new(TimestampNanosecondArray::from(signal_ts).with_timezone("UTC")),
            Arc::new(TimestampNanosecondArray::from(execution_ts).with_timezone("UTC")),
            Arc::new(UInt32Array::from(symbol_ids)),
            Arc::new(UInt8Array::from(sides)),
            Arc::new(Float64Array::from(quantity)),
            Arc::new(Float64Array::from(intended_price)),
            Arc::new(Float64Array::from(fill_price)),
            Arc::new(Float64Array::from(fees)),
            Arc::new(Float64Array::from(slippage)),
            Arc::new(BooleanArray::from(is_gap_bar)),
            Arc::new(UInt8Array::from(exit_reasons)),
        ],
    )
    .map_err(Into::into)
}

/// Detect the native bar interval from aligned timestamps.
/// Returns spacing between first two bars in nanoseconds,
/// or falls back to 1 second if fewer than 2 bars.
fn detect_native_interval_ns(timestamps: &[i64]) -> i64 {
    if timestamps.len() >= 2 {
        (timestamps[1] - timestamps[0]).max(1_000_000_000)
    } else {
        1_000_000_000 // 1 second default
    }
}

fn effective_output_nanos(config: &BacktestConfig) -> i64 {
    let nanos = match &config.output_resolution {
        Some(interval) => (interval.as_seconds() * 1_000_000_000.0) as i64,
        None => {
            // Auto: use resample_to if set (that's the effective signal resolution),
            // otherwise fall back to bar_interval. Cap at 1h minimum.
            let effective = config.resample_to.as_ref().unwrap_or(&config.bar_interval);
            let eff_nanos = (effective.as_seconds() * 1_000_000_000.0) as i64;
            let one_hour = 3_600_000_000_000_i64;
            eff_nanos.max(one_hour)
        }
    };

    // License gate: Community tier is capped at daily resolution.
    // Skip in test builds (integration tests set BT_UNLOCKED=1).
    let unlocked = std::env::var("BT_UNLOCKED").is_ok_and(|v| v == "1");
    if !unlocked {
        let min_nanos = bt_license::min_resolution_secs() as i64 * 1_000_000_000;
        if nanos < min_nanos {
            tracing::debug!(
                "output_resolution capped to daily (Pro license required for sub-daily resolution)"
            );
            return min_nanos;
        }
    }
    nanos
}

/// Return indices of the last row in each time bucket, plus first and last rows.
fn downsample_indices(timestamps: &[i64], resolution_ns: i64) -> Vec<usize> {
    let n = timestamps.len();
    if n == 0 {
        return Vec::new();
    }
    if resolution_ns <= 0 {
        return (0..n).collect();
    }

    let mut result = Vec::new();
    let mut current_bucket = timestamps[0] / resolution_ns;

    for i in 0..n {
        let bucket = timestamps[i] / resolution_ns;
        if bucket != current_bucket {
            // Emit last index of previous bucket.
            result.push(i - 1);
            current_bucket = bucket;
        }
    }
    // Emit last index (last of final bucket).
    result.push(n - 1);

    // Always include the first row.
    if result[0] != 0 {
        result.insert(0, 0);
    }

    result
}

/// Expand coarse-resolution targets to fine resolution using **point
/// placement** shifted by one coarse bar.
///
/// Signal from coarse bar G is placed at the **first fine bar** of group
/// G+1 only; all other fine bars receive NaN (= "hold, no new signal").
///
/// This prevents two issues:
/// 1. **Look-ahead bias**: the signal is only available after the coarse
///    bar that produced it has fully closed.
/// 2. **Pyramiding spam**: when `pyramiding=true` the `last_raw_target`
///    skip is disabled, so broadcasting a constant value across all fine
///    bars would trigger a trade every fine bar.  Point placement
///    ensures exactly one trade per coarse bar.
///
/// Non-delta modes are unaffected because the `last_raw_target` skip
/// already deduplicates identical consecutive signals.
fn expand_to_fine_resolution(
    coarse: &Float64Array,
    groups: &[(usize, usize)],
    fine_len: usize,
) -> Float64Array {
    let n_groups = groups.len();
    if coarse.null_count() == 0 {
        let mut fine = vec![f64::NAN; fine_len];
        for g in 0..coarse.len().min(n_groups).saturating_sub(1) {
            let val = coarse.value(g);
            let first_fine = groups[g + 1].0;
            fine[first_fine] = val;
        }
        Float64Array::from(fine)
    } else {
        let mut fine: Vec<Option<f64>> = vec![None; fine_len];
        for g in 0..coarse.len().min(n_groups).saturating_sub(1) {
            if !coarse.is_null(g) {
                let first_fine = groups[g + 1].0;
                fine[first_fine] = Some(coarse.value(g));
            }
        }
        Float64Array::from(fine)
    }
}

/// Extract column names referenced by a compiled expression.
fn compiled_expr_columns(expr: &CompiledExpr) -> HashSet<String> {
    let mut cols = HashSet::new();
    for node in &expr.nodes {
        if let CompiledOp::Column(name) = &node.op {
            cols.insert(name.clone());
        }
    }
    cols
}

/// Topological sort of signal names based on inter-signal dependencies.
///
/// A signal S depends on signal T if S's compiled expression references
/// a column named T. Signals with no inter-signal deps are emitted first
/// (in alphabetical order for determinism).
fn topological_signal_order(signals: &HashMap<String, CompiledExpr>) -> Result<Vec<String>> {
    let signal_names: HashSet<String> = signals.keys().cloned().collect();

    // Build adjacency: for each signal, which other signals must be evaluated first?
    let mut rdeps: HashMap<String, Vec<String>> = HashMap::new();
    let mut in_degree: HashMap<String, usize> = HashMap::new();

    for name in &signal_names {
        rdeps.entry(name.clone()).or_default();
        in_degree.entry(name.clone()).or_insert(0);
    }

    for (name, expr) in signals {
        let cols = compiled_expr_columns(expr);
        for col in cols {
            if signal_names.contains(&col) && col != *name {
                rdeps.entry(col).or_default().push(name.clone());
                *in_degree.entry(name.clone()).or_insert(0) += 1;
            }
        }
    }

    // Kahn's algorithm
    let mut queue: VecDeque<String> = {
        let mut roots: Vec<String> = in_degree
            .iter()
            .filter(|(_, &deg)| deg == 0)
            .map(|(name, _)| name.clone())
            .collect();
        roots.sort();
        roots.into_iter().collect()
    };

    let mut order = Vec::with_capacity(signals.len());
    while let Some(name) = queue.pop_front() {
        let mut next_ready = Vec::new();
        if let Some(dependents) = rdeps.get(&name) {
            for dependent in dependents {
                let deg = in_degree.get_mut(dependent).unwrap();
                *deg -= 1;
                if *deg == 0 {
                    next_ready.push(dependent.clone());
                }
            }
        }
        next_ready.sort();
        for n in next_ready {
            queue.push_back(n);
        }
        order.push(name);
    }

    if order.len() != signals.len() {
        return Err(BtError::Strategy(
            "circular dependency detected among signals".to_string(),
        ));
    }

    Ok(order)
}

/// Like `topological_signal_order` but returns signals grouped by dependency
/// level. Signals within the same level are independent and can be evaluated
/// in parallel.
fn topological_signal_levels(signals: &HashMap<String, CompiledExpr>) -> Result<Vec<Vec<String>>> {
    let signal_names: HashSet<String> = signals.keys().cloned().collect();

    let mut rdeps: HashMap<String, Vec<String>> = HashMap::new();
    let mut in_degree: HashMap<String, usize> = HashMap::new();

    for name in &signal_names {
        rdeps.entry(name.clone()).or_default();
        in_degree.entry(name.clone()).or_insert(0);
    }

    for (name, expr) in signals {
        let cols = compiled_expr_columns(expr);
        for col in cols {
            if signal_names.contains(&col) && col != *name {
                rdeps.entry(col).or_default().push(name.clone());
                *in_degree.entry(name.clone()).or_insert(0) += 1;
            }
        }
    }

    let mut levels = Vec::new();
    let mut queue: Vec<String> = {
        let mut roots: Vec<String> = in_degree
            .iter()
            .filter(|(_, &deg)| deg == 0)
            .map(|(name, _)| name.clone())
            .collect();
        roots.sort();
        roots
    };

    let mut processed = 0;
    while !queue.is_empty() {
        // All signals in `queue` have in-degree 0 → independent of each other
        levels.push(queue.clone());
        processed += queue.len();

        let mut next_queue = Vec::new();
        for name in &queue {
            if let Some(dependents) = rdeps.get(name) {
                for dependent in dependents {
                    let deg = in_degree.get_mut(dependent).unwrap();
                    *deg -= 1;
                    if *deg == 0 {
                        next_queue.push(dependent.clone());
                    }
                }
            }
        }
        next_queue.sort();
        next_queue.dedup();
        queue = next_queue;
    }

    if processed != signals.len() {
        return Err(BtError::Strategy(
            "circular dependency detected among signals".to_string(),
        ));
    }

    Ok(levels)
}
