Skip to main content

quantwave_backtest/
lib.rs

1//! Core vectorized portfolio simulation engine (Rust + Polars long format).
2//!
3//! This crate provides the foundation for QuantWave's backtesting capabilities
4//! under epic quantwave-gwx / task quantwave-1hr + quantwave-ug9t (streaming
5//! simulation + full batch-vs-streaming parity verification).
6//!
7//! ## Batch vs Streaming Parity (quantwave-ug9t)
8//! - `BacktestEngine::run` / `backtest_simple_bool_signal`: pure vectorized batch path
9//!   (pre-computed signals in DF column; fast for research sweeps). Signal f64 value
10//!   now interpreted as signed exposure (0=flat, >0=long, <0=short units).
11//! - `run_streaming_simulation`: streaming path driven by any `Next<&Bar, Output=StrategySignal>`
12//!   generator (closer to live trading loop, supports rich metadata from features/PA/regimes).
13//! - Shared internal `run_simulation` core guarantees identical execution semantics
14//!   (costs, fills, equity, trade recording) when fed equivalent signals.
15//! - Mandatory parity tests (in this file) enforce equity curves, trade counts/pnls/stats
16//!   match within documented tolerance for strategies using regime filters + feature
17//!   thresholds + rich PA structs (pole height sizing).
18//!
19//! Design principles (per project AGENTS.md):
20//! - Long-format multi-symbol first-class (symbol, timestamp, ohlcv, signals).
21//! - Ready for rich Struct signals (e.g. from future PA detectors containing
22//!   `pole_height`, `strength`, etc. for dynamic sizing/conviction).
23//! - Basic realistic execution: commission + slippage.
24//! - T+1 execution via `BacktestConfig.execution_delay` (`SameBar` default, `NextBar`
25//!   for polars-backtest-style next-bar fills — quantwave-cr6v.8).
26//! - Stop-loss / take-profit / trailing via `BacktestConfig.stop_config` (RaptorBT-inspired
27//!   clean-room — quantwave-cr6v.9).
28//! - Struct `signal_col` auto-parse with pole_height sizing (quantwave-cr6v.11).
29//! - Param sweep helper `run_param_sweep` / `SweepVariant` (quantwave-cr6v.12).
30//! - Criterion benches vs naive row-loop (`benches/backtest_vs_naive.rs`, cr6v.13).
31//! - Walk-forward OOS + trade bootstrap Monte Carlo (cr6v.14).
32//! - Cross-sectional factor panel rank/long-short (sigc-inspired, cr6v.15).
33//! - `LiveBridge` trait for future Nautilus adapter (LGPL — cr6v.16).
34//! - Vectorized foundation now; streaming parity (Next<T> from quantwave-core)
35//!   and full rich PA/ML integration in sibling tasks (ug9t, 06sz).
36//! - All new code will eventually carry batch-vs-streaming proptests.
37//!
38//! Sources (recorded per AGENTS + 366 research):
39//! - Primary alignment: Yvictor/polars-backtest (native Polars long-format
40//!   multi-symbol with realistic costs/execution model).
41//! - Vectorized portfolio concepts (clean-room): vectorbt (Apache-2 + Commons Clause)
42//!   patterns for signal->position->pnl vectorization; RaptorBT analogs.
43//! - Rich signal metadata readiness: MQL5 PA series (Parts 69-70, 67) via
44//!   quantwave-366 notes — structured outputs (pole_height etc.) for backtester
45//!   consumption, not just viz. quantwave-06sz complete for integration (batch
46//!   exposure + streaming StrategySignal.metadata + verified parity with pole
47//!   sizing + regime/feature filters; batch native Struct col is extension point).
48//! - Current thin steel-thread: docs/examples/notebooks/strategy_backtest.py
49//!   (synthetic + SuperTrend struct only; no PnL/costs/trades yet).
50//! - Parity framework pattern: modeled on quantwave-core/src/test_utils.rs
51//!   `check_batch_streaming_parity` + indicator proptests (e.g. kinematic_kalman.rs).
52//! - Regime: quantwave-core/src/regimes/tar.rs (TAR for simple filter in parity test).
53//! - Features: quantwave-core/src/features/cyber_cycle.rs (CyberCycleFeatureExtractor).
54//! - Synthetic PA pole for test (non-production): concept from MQL5 PA + Ehlers
55//!   turning points (see artifacts/anticipating_turning_points*.txt); recorded here
56//!   per AGENTS "if no source validate".
57//!
58//! Universal Indicator / Next<T> relevance: The engine itself is vectorized
59//! (batch) for v0.1. Streaming simulation mode (feeding signals from Next<T>
60//! strategy state machines) + full parity proptests implemented in quantwave-ug9t.
61//! The crate re-exports core traits for future hybrid use.
62//!
63//! Tolerance policy (documented for ug9t verification):
64//! - Equity curve values: relative + abs epsilon 1e-8 (float accum).
65//! - Trade count: exact.
66//! - PnL / final equity / stats: 1e-6 tolerance (costs/rounding).
67//! - Prices in trades: 1e-8.
68//! - Failure modes: unsorted data, NaNs in prices, generator state drift,
69//!   mismatched exposure semantics, open position at end handling, regime/feature
70//!   init bias on first bars (warmup NaNs tolerated in features).
71//!
72//! NO root-level tests/ dirs created. Tests live inside this crate
73//! (#[cfg(test)]). Respects quantwave-core/tests/ rule for gold-standard
74//! indicator work.
75
76mod cross_sectional;
77mod live_bridge;
78mod metrics;
79mod monte_carlo;
80mod portfolio;
81mod stops;
82mod sweep;
83mod tearsheet;
84mod walk_forward;
85
86use chrono::{DateTime, Utc};
87use polars::prelude::*;
88pub use cross_sectional::{
89    assign_long_short_exposure, neutralize_factor, run_cross_sectional_backtest, winsorize_factor,
90    zscore_factor, CrossSectionalConfig,
91};
92pub use live_bridge::{
93    LiveBridge, LiveBridgeError, LiveSignalEvent, RecordingLiveBridge,
94};
95pub use metrics::{BacktestReport, PerformanceMetrics};
96pub use tearsheet::{render_tearsheet_html, TearsheetOptions};
97pub use monte_carlo::{
98    monte_carlo_trade_bootstrap, MonteCarloConfig, MonteCarloSummary,
99    monte_carlo_return_paths, MonteCarloReturnConfig, MonteCarloPathSummary,
100};
101pub use sweep::{run_param_sweep, single_param_variants, SweepVariant};
102pub use portfolio::{
103    PortfolioAllocator, PortfolioBar, PortfolioMode, run_shared_capital_streaming_simulation,
104};
105pub use stops::{StopConfig, StopEvaluationMode};
106pub use walk_forward::{run_walk_forward, run_walk_forward_optimize, WalkForwardConfig};
107#[allow(unused_imports)]
108use quantwave_core::traits::Next; // Re-exported for future streaming parity work (used in hybrid mode later per quantwave-ug9t)
109use serde::{Deserialize, Serialize};
110use std::collections::HashMap;
111use thiserror::Error;
112
113/// Errors from the simulation engine.
114#[derive(Error, Debug)]
115pub enum BacktestError {
116    #[error("Polars error during simulation: {0}")]
117    Polars(#[from] PolarsError),
118
119    #[error("Invalid input: {0}")]
120    InvalidInput(String),
121
122    #[error("Data must be sorted by timestamp (and symbol for multi-symbol runs)")]
123    UnsortedData,
124}
125
126/// Basic execution cost model.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct CostModel {
129    /// Commission in basis points (e.g. 10.0 = 0.10%).
130    pub commission_bps: f64,
131    /// Slippage in basis points applied to fill price (e.g. 5.0 = 0.05%).
132    pub slippage_bps: f64,
133    /// Initial cash balance (default 100_000.0).
134    pub initial_cash: f64,
135}
136
137impl Default for CostModel {
138    fn default() -> Self {
139        Self {
140            commission_bps: 5.0, // 0.05% realistic for many instruments
141            slippage_bps: 2.0,   // 0.02% minimal slippage
142            initial_cash: 100_000.0,
143        }
144    }
145}
146
147/// Pluggable commission model (n1yc.2, QF-Lib inspired).
148pub trait CommissionModel: Send + Sync + std::fmt::Debug {
149    fn calculate_commission(&self, fill_quantity: f64, fill_price: f64) -> f64;
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, Default)]
153pub struct BpsCommissionModel {
154    /// Commission in basis points (e.g. 10.0 = 0.10%).
155    pub bps: f64,
156}
157
158impl CommissionModel for BpsCommissionModel {
159    fn calculate_commission(&self, fill_quantity: f64, fill_price: f64) -> f64 {
160        (fill_quantity.abs() * fill_price) * (self.bps / 10_000.0)
161    }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct FixedPerShareCommissionModel {
166    pub per_share: f64,
167}
168
169impl CommissionModel for FixedPerShareCommissionModel {
170    fn calculate_commission(&self, fill_quantity: f64, _fill_price: f64) -> f64 {
171        fill_quantity.abs() * self.per_share
172    }
173}
174
175/// Pluggable slippage model (n1yc.2/3).
176pub trait SlippageModel: Send + Sync + std::fmt::Debug {
177    fn apply(&self, price: f64, quantity: f64, is_buy: bool, adv: Option<f64>) -> f64;
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct BpsSlippageModel {
182    pub bps: f64,
183}
184
185impl SlippageModel for BpsSlippageModel {
186    fn apply(&self, price: f64, _quantity: f64, is_buy: bool, _adv: Option<f64>) -> f64 {
187        let s = self.bps / 10_000.0;
188        if is_buy { price * (1.0 + s) } else { price * (1.0 - s) }
189    }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, Default)]
193pub struct SquareRootMarketImpactSlippage {
194    pub impact_coef: f64,
195    pub max_participation: f64,
196}
197
198impl SlippageModel for SquareRootMarketImpactSlippage {
199    fn apply(&self, price: f64, quantity: f64, is_buy: bool, adv: Option<f64>) -> f64 {
200        let adv = adv.unwrap_or(1_000_000.0);
201        let part = (quantity.abs() / adv).min(self.max_participation);
202        let impact = self.impact_coef * part.sqrt();
203        if is_buy { price * (1.0 + impact) } else { price * (1.0 - impact) }
204    }
205}
206
207/// When a signal observed at bar *t* may be executed (clean-room polars-backtest T+1).
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
209pub enum ExecutionDelay {
210    /// T+0: signal at bar *t* fills at bar *t* close (default).
211    #[default]
212    SameBar,
213    /// T+1: signal at bar *t* fills at bar *t+1* close (no same-bar look-ahead).
214    NextBar,
215}
216
217/// Execution model config (n1yc.2/3). Supports simple + high-fidelity with realistic models.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub enum ExecutionModel {
220    Simple(CostModel),
221    HighFidelity {
222        commission: BpsCommissionModel,
223        slippage: SquareRootMarketImpactSlippage,
224    },
225}
226
227impl Default for ExecutionModel {
228    fn default() -> Self {
229        ExecutionModel::Simple(CostModel::default())
230    }
231}
232
233impl ExecutionModel {
234    pub fn commission_for(&self, qty: f64, px: f64) -> f64 {
235        match self {
236            ExecutionModel::Simple(cm) => (qty.abs() * px) * (cm.commission_bps / 10_000.0),
237            ExecutionModel::HighFidelity { commission, .. } => commission.calculate_commission(qty, px),
238        }
239    }
240    pub fn slippage_price(&self, price: f64, qty: f64, is_buy: bool, adv: Option<f64>) -> f64 {
241        match self {
242            ExecutionModel::Simple(cm) => {
243                let s = cm.slippage_bps / 10_000.0;
244                if is_buy { price * (1.0 + s) } else { price * (1.0 - s) }
245            }
246            ExecutionModel::HighFidelity { slippage, .. } => slippage.apply(price, qty, is_buy, adv),
247        }
248    }
249}
250
251/// Rich-Metadata-Aware Position Sizer (n1yc.1).
252/// Inspired by QF-Lib InitialRiskPositionSizer + Signal.fraction_at_risk.
253/// Supports PA structs via "pole_height_atr" or explicit "fraction_at_risk" in StrategySignal.metadata
254/// (populated by 06sz PAEvent integration and feature extractors).
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct InitialRiskPositionSizer {
257    /// Risk per trade as fraction of current equity (e.g. 0.01 for 1%).
258    pub initial_risk: f64,
259    /// Cap on target % of equity (e.g. 0.25).
260    pub max_target_pct: f64,
261}
262
263impl Default for InitialRiskPositionSizer {
264    fn default() -> Self {
265        Self { initial_risk: 0.01, max_target_pct: 0.25 }
266    }
267}
268
269impl InitialRiskPositionSizer {
270    /// Given raw signal exposure (or suggested) + rich metadata from PA/ features,
271    /// return the risk-budgeted target exposure in units.
272    /// Uses current equity and price for conversion.
273    pub fn compute_sized_exposure(
274        &self,
275        raw_exposure: f64,
276        meta: &Option<HashMap<String, f64>>,
277        price: f64,
278        equity: f64,
279    ) -> f64 {
280        let sign = if raw_exposure > 0.0 { 1.0 } else if raw_exposure < 0.0 { -1.0 } else { 0.0 };
281        if let Some(m) = meta {
282            // Prefer explicit fraction_at_risk from rich PA signal
283            if let Some(frac) = m.get("fraction_at_risk").copied() {
284                if frac > 0.0 {
285                    let target_pct = (self.initial_risk / frac).min(self.max_target_pct);
286                    let target_units = target_pct * equity / price * sign;
287                    return target_units;
288                }
289            }
290            // Fallback: PA pole_height_atr (common from Flag/H&S/MarketStructure)
291            if let Some(pole) = m.get("pole_height_atr").copied() {
292                if pole > 0.0 {
293                    // Treat pole_atr as risk unit proxy (adjust k per your PA convention; here illustrative 1% / pole)
294                    let frac = 0.01 / pole;
295                    let target_pct = (self.initial_risk / frac).min(self.max_target_pct);
296                    let target_units = target_pct * equity / price * sign;
297                    return target_units;
298                }
299            }
300        }
301        raw_exposure
302    }
303}
304
305/// Configuration for a backtest run.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct BacktestConfig {
308    pub cost_model: CostModel,
309    /// Column names (customizable for long-format flexibility).
310    pub timestamp_col: String,
311    pub symbol_col: Option<String>,
312    pub close_col: String,
313    /// Optional high column for OHLC touched-exit stop evaluation.
314    pub high_col: Option<String>,
315    /// Optional low column for OHLC touched-exit stop evaluation.
316    pub low_col: Option<String>,
317    /// Signal column: f64 or bool/int. >0 long, <0 short, 0 flat (units for sizing).
318    /// For rich PA + features/regime in batch DF path: pre-compute an 'exposure' col
319    /// (e.g. via Polars exprs on ta.features + PA struct fields) and/or use the
320    /// streaming path (run_streaming_simulation + Next impl emitting StrategySignal
321    /// with metadata for pole_height etc). Struct `signal_col` auto-parses
322    /// `{exposure, long, pole_height, …}` fields (quantwave-cr6v.11).
323    pub signal_col: String,
324    /// Optional boolean col: dynamic entry filter (AND with signal). For regime
325    /// labels/probs or feature thresholds (ta.features outputs). Batch path uses
326    /// false forces exposure 0 (batch + streaming parity in quantwave-cr6v.3).
327    pub entry_filter_col: Option<String>,
328    /// Optional f64 col: position size modulator (multiplies signal exposure).
329    /// E.g. pole_height normalized or regime_prob. Enables 'sized by pole'.
330    pub size_multiplier_col: Option<String>,
331
332    // v0.2 rich execution (n1yc.2/3) + sizer (n1yc.1)
333    pub execution_model: ExecutionModel,
334    /// Signal-to-fill timing (quantwave-cr6v.8). Default `SameBar` preserves T+0 behavior.
335    pub execution_delay: ExecutionDelay,
336    /// Optional stop-loss / take-profit / trailing (quantwave-cr6v.9).
337    pub stop_config: StopConfig,
338    /// If Some, the engine will apply risk-budgeted sizing using fraction_at_risk / pole_height_atr
339    /// from StrategySignal.metadata (or PAEvent converted) on top of raw exposure.
340    pub position_sizer: Option<InitialRiskPositionSizer>,
341    /// Multi-symbol capital model (`IndependentBooks` default — quantwave-qzpi.6).
342    pub portfolio_mode: PortfolioMode,
343    /// Budget split when opening positions in `SharedCapital` mode.
344    pub portfolio_allocator: PortfolioAllocator,
345}
346
347impl Default for BacktestConfig {
348    fn default() -> Self {
349        Self {
350            cost_model: CostModel::default(),
351            timestamp_col: "timestamp".to_string(),
352            symbol_col: None,
353            close_col: "close".to_string(),
354            high_col: None,
355            low_col: None,
356            signal_col: "signal".to_string(),
357            entry_filter_col: None,
358            size_multiplier_col: None,
359            execution_model: ExecutionModel::default(),
360            execution_delay: ExecutionDelay::default(),
361            stop_config: StopConfig::default(),
362            position_sizer: None,
363            portfolio_mode: PortfolioMode::default(),
364            portfolio_allocator: PortfolioAllocator::default(),
365        }
366    }
367}
368
369/// Map simulation bar index to the signal bar used for execution decisions.
370fn signal_bar_index(bar: usize, delay: ExecutionDelay) -> Option<usize> {
371    match delay {
372        ExecutionDelay::SameBar => Some(bar),
373        ExecutionDelay::NextBar => bar.checked_sub(1),
374    }
375}
376
377/// A completed (or open) trade record. Rich enough for later PA metadata.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct Trade {
380    pub trade_id: u32,
381    pub symbol: Option<String>,
382    pub side: i8, // 1 = long (MVP), -1 future short
383    pub entry_ts: DateTime<Utc>,
384    pub entry_price: f64,
385    pub entry_fill_price: f64, // after slippage
386    pub exit_ts: Option<DateTime<Utc>>,
387    pub exit_price: Option<f64>,
388    pub exit_fill_price: Option<f64>,
389    pub pnl_gross: f64,
390    pub costs: f64,
391    pub pnl_net: f64,
392    /// Quantity (exposure) entered for this trade. Supports variable sizing from
393    /// rich PA (pole_height) or feature signals (was hardcoded 1.0 pre-ug9t).
394    pub quantity: f64,
395    /// Rich signal metadata at entry (e.g. pole_height from PA struct, regime,
396    /// cycle_momentum). Populated in streaming Next<T> path; batch scalar uses None.
397    pub entry_metadata: Option<HashMap<String, f64>>,
398}
399
400/// Per-bar equity snapshot (for the equity curve DF).
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct EquityPoint {
403    pub ts: DateTime<Utc>,
404    pub symbol: Option<String>, // None for aggregated in MVP
405    pub equity: f64,
406    pub cash: f64,
407    pub position: f64, // units (signed)
408    pub close: f64,
409}
410
411/// Rich result bundle returned by the engine (Polars DataFrames + summary stats).
412#[derive(Debug)]
413pub struct BacktestResult {
414    /// Trade blotter as Polars DataFrame (one row per trade).
415    pub trades: DataFrame,
416    /// Equity curve as Polars DataFrame (one row per bar).
417    pub equity_curve: DataFrame,
418    /// Summary statistics (trade count, net pnl, initial/final equity, etc.).
419    pub stats: HashMap<String, f64>,
420}
421
422impl BacktestResult {
423    /// Compute [`PerformanceMetrics`] from this result (quantwave-cr6v.1).
424    pub fn metrics(&self) -> PerformanceMetrics {
425        PerformanceMetrics::from_result(self)
426    }
427}
428
429/// A minimal bar struct for driving streaming simulation.
430#[derive(Debug, Clone)]
431pub struct Bar {
432    pub ts: DateTime<Utc>,
433    pub close: f64,
434    /// Bar high (required for `StopEvaluationMode::OhlcTouched` in streaming path).
435    pub high: Option<f64>,
436    /// Bar low (required for `StopEvaluationMode::OhlcTouched` in streaming path).
437    pub low: Option<f64>,
438}
439
440/// Rich signal output produced by a `Next<&Bar, Output = StrategySignal>` generator.
441/// Enables the streaming simulation mode (quantwave-ug9t) while carrying rich
442/// metadata (pole height sizing, regime, features) into Trade records.
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct StrategySignal {
445    /// Signed exposure in units (>0 long, <0 short, 0 flat). Variable sizing supported.
446    pub exposure: f64,
447    /// Optional rich metadata for the decision (e.g. "pole_height" => 2.34,
448    /// "regime" => 0.0 for Steady). Used by parity test and future rich PA consumers.
449    pub metadata: Option<HashMap<String, f64>>,
450}
451
452impl Default for StrategySignal {
453    fn default() -> Self {
454        Self {
455            exposure: 0.0,
456            metadata: None,
457        }
458    }
459}
460
461/// Simple struct for rich PA detector outputs (placeholder/stub for integration;
462/// full detectors in future PA work). Can be turned into StrategySignal or
463/// serialized into Polars Struct column for batch runs. Per quantwave-06sz.
464#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
465pub struct PAEvent {
466    /// Triggers long (or positive exposure).
467    pub long: bool,
468    /// Pole height from flag/PA pattern - primary for sizing/conviction (06sz).
469    pub pole_height: Option<f64>,
470    /// Strength/conviction score.
471    pub strength: Option<f64>,
472}
473
474impl PAEvent {
475    /// Convert to [`StrategySignal`] (streaming / struct parity helper).
476    pub fn to_strategy_signal(&self) -> StrategySignal {
477        let mut meta = HashMap::new();
478        if let Some(p) = self.pole_height {
479            meta.insert("pole_height".to_string(), p);
480        }
481        if let Some(s) = self.strength {
482            meta.insert("strength".to_string(), s);
483        }
484        let exposure = if self.long {
485            self.pole_height
486                .map(pole_height_to_exposure)
487                .unwrap_or(1.0)
488        } else {
489            0.0
490        };
491        StrategySignal {
492            exposure,
493            metadata: if meta.is_empty() { None } else { Some(meta) },
494        }
495    }
496}
497
498/// Map PA pole height to exposure units (matches ug9t streaming parity test).
499pub fn pole_height_to_exposure(pole_height: f64) -> f64 {
500    (pole_height / 4.0).clamp(0.4, 2.2)
501}
502
503/// Parse one Polars Struct signal row into exposure + metadata (quantwave-cr6v.11).
504///
505/// Supported fields (clean-room 06sz contract):
506/// - `exposure` (f64): signed units, preferred when present
507/// - `long` / `short` (bool): direction when exposure absent
508/// - `pole_height`, `pole_height_atr`, `pole_length_atr` (f64): sizing + metadata
509/// - `fraction_at_risk`, `strength`, and other numeric fields → metadata
510pub fn parse_struct_signal_row(
511    ca: &StructChunked,
512    i: usize,
513) -> Result<(f64, Option<HashMap<String, f64>>), BacktestError> {
514    let mut meta = HashMap::new();
515
516    let exposure_direct = struct_field_f64(ca, "exposure", i);
517    let long = struct_field_bool(ca, "long", i);
518    let short = struct_field_bool(ca, "short", i);
519
520    if let DataType::Struct(fields) = ca.dtype() {
521        for field in fields {
522            let key = field.name.as_str();
523            if matches!(key, "exposure" | "long" | "short") {
524                continue;
525            }
526            if let Some(v) = struct_field_f64(ca, key, i) {
527                if v.is_finite() {
528                    meta.insert(key.to_string(), v);
529                }
530            }
531        }
532    }
533
534    let pole = ["pole_height", "pole_height_atr", "pole_length_atr"]
535        .iter()
536        .find_map(|name| meta.get(*name).copied())
537        .filter(|v| *v > 0.0);
538
539    let exposure = if let Some(e) = exposure_direct {
540        if e.is_finite() && e != 0.0 {
541            e
542        } else if short.unwrap_or(false) {
543            let mag = pole.map(pole_height_to_exposure).unwrap_or(1.0);
544            -mag
545        } else if long.unwrap_or(false) {
546            pole.map(pole_height_to_exposure).unwrap_or(1.0)
547        } else {
548            0.0
549        }
550    } else if short.unwrap_or(false) {
551        let mag = pole.map(pole_height_to_exposure).unwrap_or(1.0);
552        -mag
553    } else if long.unwrap_or(false) {
554        pole.map(pole_height_to_exposure).unwrap_or(1.0)
555    } else {
556        0.0
557    };
558
559    let metadata = if meta.is_empty() { None } else { Some(meta) };
560    Ok((exposure, metadata))
561}
562
563fn struct_field_f64(ca: &StructChunked, name: &str, i: usize) -> Option<f64> {
564    let field = ca.field_by_name(name).ok()?;
565    field.f64().ok().and_then(|arr| arr.get(i))
566}
567
568fn struct_field_bool(ca: &StructChunked, name: &str, i: usize) -> Option<bool> {
569    let field = ca.field_by_name(name).ok()?;
570    field.bool().ok().and_then(|arr| arr.get(i))
571}
572
573/// Core vectorized engine (MVP).
574///
575/// Takes a (sorted) long-format DataFrame containing at minimum:
576/// timestamp, close, signal (bool/f64; value >0 interpreted as desired exposure
577/// in units for variable sizing support added in ug9t).
578///
579/// Generalized from unit-size flips (1hr) to exposure-driven for feature/PA
580/// sizing parity verification. See `run_streaming_simulation` for Next<T> path.
581/// When `BacktestConfig.symbol_col` is set, runs independent per-symbol simulations
582/// and returns symbol-tagged trades plus per-symbol and portfolio equity curves.
583pub struct BacktestEngine {
584    config: BacktestConfig,
585}
586
587impl BacktestEngine {
588    pub fn new(config: BacktestConfig) -> Self {
589        Self { config }
590    }
591
592    pub fn with_default_costs() -> Self {
593        Self::new(BacktestConfig::default())
594    }
595
596    /// Run backtest and attach [`PerformanceMetrics`] in a [`BacktestReport`].
597    pub fn backtest_with_report(&self, lf: LazyFrame) -> Result<BacktestReport, BacktestError> {
598        let result = self.run(lf)?;
599        let metrics = PerformanceMetrics::from_result(&result);
600        Ok(BacktestReport { result, metrics })
601    }
602
603    /// Run vectorized simulation on a LazyFrame (collected internally for state machine).
604    /// Input **must** be sorted ascending by timestamp (then symbol if multi).
605    /// Returns rich Polars results.
606    pub fn run(&self, lf: LazyFrame) -> Result<BacktestResult, BacktestError> {
607        let df = lf.collect()?;
608
609        if df.height() == 0 {
610            return Err(BacktestError::InvalidInput("empty dataframe".into()));
611        }
612
613        let ts_col = &self.config.timestamp_col;
614        let close_col = &self.config.close_col;
615        let sig_col = &self.config.signal_col;
616
617        for c in [ts_col, close_col, sig_col] {
618            if df.column(c).is_err() {
619                return Err(BacktestError::InvalidInput(format!(
620                    "missing column: {}",
621                    c
622                )));
623            }
624        }
625
626        if self.config.symbol_col.is_some() {
627            return match self.config.portfolio_mode {
628                PortfolioMode::SharedCapital => self.run_shared_capital_multi_symbol(df),
629                PortfolioMode::IndependentBooks => self.run_multi_symbol(df),
630            };
631        }
632
633        self.run_single_symbol(df)
634    }
635
636    pub fn run_metrics_only(&self, lf: LazyFrame) -> Result<PerformanceMetrics, BacktestError> {
637        let df = lf.collect()?;
638
639        if df.height() == 0 {
640            return Err(BacktestError::InvalidInput("empty dataframe".into()));
641        }
642
643        let ts_col = &self.config.timestamp_col;
644        let close_col = &self.config.close_col;
645        let sig_col = &self.config.signal_col;
646
647        for c in [ts_col, close_col, sig_col] {
648            if df.column(c).is_err() {
649                return Err(BacktestError::InvalidInput(format!(
650                    "missing column: {}",
651                    c
652                )));
653            }
654        }
655
656        if self.config.symbol_col.is_some() {
657            return match self.config.portfolio_mode {
658                PortfolioMode::SharedCapital => self.run_metrics_shared_capital(df),
659                PortfolioMode::IndependentBooks => self.run_metrics_multi_symbol(df),
660            };
661        }
662
663        self.run_metrics_single_symbol(df)
664    }
665
666    fn run_metrics_single_symbol(&self, df: DataFrame) -> Result<PerformanceMetrics, BacktestError> {
667        let (trades, equity_points) = self.simulate_dataframe(&df, None)?;
668        Ok(PerformanceMetrics::from_raw(&trades, &equity_points, self.per_symbol_initial_cash()))
669    }
670
671    fn run_metrics_multi_symbol(&self, df: DataFrame) -> Result<PerformanceMetrics, BacktestError> {
672        let sym_col = self
673            .config
674            .symbol_col
675            .as_ref()
676            .expect("symbol_col set");
677
678        if df.column(sym_col).is_err() {
679            return Err(BacktestError::InvalidInput(format!(
680                "missing column: {}",
681                sym_col
682            )));
683        }
684
685        let ts_series = df.column(&self.config.timestamp_col)?.clone();
686        let timestamps = self.extract_timestamps(&ts_series)?;
687        let symbols = extract_string_column(df.column(sym_col)?.clone())?;
688        validate_sorted_timestamp_symbol(&timestamps, &symbols)?;
689
690        let mut unique_symbols: Vec<String> = Vec::new();
691        let mut seen = std::collections::HashSet::new();
692        for s in &symbols {
693            if seen.insert(s.clone()) {
694                unique_symbols.push(s.clone());
695            }
696        }
697
698        let mut all_trades: Vec<Trade> = Vec::new();
699        let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
700
701        for symbol in &unique_symbols {
702            let sub = df
703                .clone()
704                .lazy()
705                .filter(col(sym_col).eq(lit(symbol.as_str())))
706                .sort(
707                    [&self.config.timestamp_col],
708                    SortMultipleOptions::default(),
709                )
710                .collect()?;
711
712            let (mut trades, equity_points) = self.simulate_dataframe(&sub, Some(symbol))?;
713            all_trades.append(&mut trades);
714            per_symbol_equity.insert(symbol.clone(), equity_points);
715        }
716
717        let portfolio_equity = aggregate_portfolio_equity(&per_symbol_equity);
718        let n_symbols = unique_symbols.len() as f64;
719        let portfolio_initial = self.per_symbol_initial_cash() * n_symbols;
720        Ok(PerformanceMetrics::from_raw(&all_trades, &portfolio_equity, portfolio_initial))
721    }
722
723    fn run_single_symbol(&self, df: DataFrame) -> Result<BacktestResult, BacktestError> {
724        let (trades, equity_points) = self.simulate_dataframe(&df, None)?;
725
726        let initial_cash = self.per_symbol_initial_cash();
727        let final_equity = equity_points
728            .last()
729            .map(|e| e.equity)
730            .unwrap_or(initial_cash);
731        let total_return = (final_equity - initial_cash) / initial_cash;
732        let num_trades = trades.len() as f64;
733
734        let mut stats = HashMap::new();
735        stats.insert("initial_cash".to_string(), initial_cash);
736        stats.insert("final_equity".to_string(), final_equity);
737        stats.insert("total_return".to_string(), total_return);
738        stats.insert("num_trades".to_string(), num_trades);
739        stats.insert("net_pnl".to_string(), final_equity - initial_cash);
740
741        Ok(BacktestResult {
742            trades: self.trades_to_df(&trades, false)?,
743            equity_curve: self.equity_to_df(&equity_points, false)?,
744            stats,
745        })
746    }
747
748    fn run_multi_symbol(&self, df: DataFrame) -> Result<BacktestResult, BacktestError> {
749        let sym_col = self
750            .config
751            .symbol_col
752            .as_ref()
753            .expect("symbol_col set");
754
755        if df.column(sym_col).is_err() {
756            return Err(BacktestError::InvalidInput(format!(
757                "missing column: {}",
758                sym_col
759            )));
760        }
761
762        let ts_series = df.column(&self.config.timestamp_col)?.clone();
763        let timestamps = self.extract_timestamps(&ts_series)?;
764        let symbols = extract_string_column(df.column(sym_col)?.clone())?;
765        validate_sorted_timestamp_symbol(&timestamps, &symbols)?;
766
767        let mut unique_symbols: Vec<String> = Vec::new();
768        let mut seen = std::collections::HashSet::new();
769        for s in &symbols {
770            if seen.insert(s.clone()) {
771                unique_symbols.push(s.clone());
772            }
773        }
774
775        let per_symbol_initial = self.per_symbol_initial_cash();
776        let mut all_trades: Vec<Trade> = Vec::new();
777        let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
778
779        for symbol in &unique_symbols {
780            let sub = df
781                .clone()
782                .lazy()
783                .filter(col(sym_col).eq(lit(symbol.as_str())))
784                .sort(
785                    [&self.config.timestamp_col],
786                    SortMultipleOptions::default(),
787                )
788                .collect()?;
789
790            let (mut trades, equity_points) = self.simulate_dataframe(&sub, Some(symbol))?;
791            all_trades.append(&mut trades);
792            per_symbol_equity.insert(symbol.clone(), equity_points);
793        }
794
795        let portfolio_equity = aggregate_portfolio_equity(&per_symbol_equity);
796        let mut combined_equity: Vec<EquityPoint> = per_symbol_equity
797            .values()
798            .flatten()
799            .cloned()
800            .collect();
801        combined_equity.extend(portfolio_equity.clone());
802
803        let n_symbols = unique_symbols.len() as f64;
804        let portfolio_initial = per_symbol_initial * n_symbols;
805        let portfolio_final = portfolio_equity
806            .last()
807            .map(|e| e.equity)
808            .unwrap_or(portfolio_initial);
809        let total_return = (portfolio_final - portfolio_initial) / portfolio_initial;
810        let num_trades = all_trades.len() as f64;
811
812        let mut stats = HashMap::new();
813        stats.insert("initial_cash".to_string(), portfolio_initial);
814        stats.insert("final_equity".to_string(), portfolio_final);
815        stats.insert("total_return".to_string(), total_return);
816        stats.insert("num_trades".to_string(), num_trades);
817        stats.insert("net_pnl".to_string(), portfolio_final - portfolio_initial);
818        stats.insert("num_symbols".to_string(), n_symbols);
819
820        Ok(BacktestResult {
821            trades: self.trades_to_df(&all_trades, true)?,
822            equity_curve: self.equity_to_df(&combined_equity, true)?,
823            stats,
824        })
825    }
826
827    fn run_shared_capital_multi_symbol(&self, df: DataFrame) -> Result<BacktestResult, BacktestError> {
828        let sym_col = self
829            .config
830            .symbol_col
831            .as_ref()
832            .expect("symbol_col set");
833
834        let ts_series = df.column(&self.config.timestamp_col)?.clone();
835        let timestamps = self.extract_timestamps(&ts_series)?;
836        let symbols = extract_string_column(df.column(sym_col)?.clone())?;
837        validate_sorted_timestamp_symbol(&timestamps, &symbols)?;
838
839        let close_ca = df.column(&self.config.close_col)?.f64()?.clone();
840        let closes: Vec<f64> = close_ca.into_iter().map(|v| v.unwrap_or(0.0)).collect();
841        let (highs, lows) = self.load_ohlc_columns(&df)?;
842        let (signal_vals, signal_metas) = self.load_signals(&df, &self.config.signal_col)?;
843        let entry_filters = self.load_entry_filters(&df)?;
844        let size_multipliers = self.load_size_multipliers(&df)?;
845
846        let mut adjusted_signals: Vec<f64> = Vec::with_capacity(signal_vals.len());
847        for i in 0..signal_vals.len() {
848            let filter = entry_filters.as_ref().and_then(|f| f.get(i).copied());
849            let mult = size_multipliers.as_ref().and_then(|m| m.get(i).copied());
850            adjusted_signals.push(apply_signal_modifiers(signal_vals[i], filter, mult));
851        }
852
853        // Apply execution delay per timestamp group (T+1 at portfolio bar level).
854        use std::collections::BTreeMap;
855        let mut by_ts: BTreeMap<DateTime<Utc>, Vec<usize>> = BTreeMap::new();
856        for (i, t) in timestamps.iter().enumerate() {
857            by_ts.entry(*t).or_default().push(i);
858        }
859        let unique_ts: Vec<DateTime<Utc>> = by_ts.keys().copied().collect();
860        let mut delayed_signals = vec![0.0; adjusted_signals.len()];
861        let mut delayed_metas: Vec<Option<HashMap<String, f64>>> = vec![None; signal_metas.len()];
862        for (gi, ts) in unique_ts.iter().enumerate() {
863            let source_by_sym: HashMap<String, (f64, Option<HashMap<String, f64>>)> =
864                if let Some(si) = signal_bar_index(gi, self.config.execution_delay) {
865                    by_ts
866                        .get(&unique_ts[si])
867                        .into_iter()
868                        .flatten()
869                        .map(|&idx| {
870                            (
871                                symbols[idx].clone(),
872                                (adjusted_signals[idx], signal_metas[idx].clone()),
873                            )
874                        })
875                        .collect()
876                } else {
877                    HashMap::new()
878                };
879            for &idx in by_ts.get(ts).unwrap_or(&vec![]) {
880                if let Some((s, m)) = source_by_sym.get(&symbols[idx]) {
881                    delayed_signals[idx] = *s;
882                    delayed_metas[idx] = m.clone();
883                }
884            }
885        }
886
887        let groups = portfolio::build_timestamp_groups(
888            &delayed_signals,
889            &delayed_metas,
890            &symbols,
891            &timestamps,
892            &closes,
893            highs.as_deref(),
894            lows.as_deref(),
895        );
896
897        let (trades, per_symbol_equity, portfolio_eq) = portfolio::simulate_shared_capital(
898            &groups,
899            &self.config.execution_model,
900            &self.config.position_sizer,
901            self.config.execution_delay,
902            &self.config.stop_config,
903            self.config.portfolio_allocator,
904        );
905
906        Self::assemble_shared_capital_result(
907            &self.config,
908            trades,
909            per_symbol_equity,
910            portfolio_eq,
911        )
912    }
913
914    fn run_metrics_shared_capital(&self, df: DataFrame) -> Result<PerformanceMetrics, BacktestError> {
915        let result = self.run_shared_capital_multi_symbol(df)?;
916        Ok(PerformanceMetrics::from_result(&result))
917    }
918
919    /// Assemble [`BacktestResult`] from shared-capital simulation output.
920    pub(crate) fn assemble_shared_capital_result(
921        config: &BacktestConfig,
922        trades: Vec<Trade>,
923        per_symbol_equity: HashMap<String, Vec<EquityPoint>>,
924        portfolio_equity: Vec<EquityPoint>,
925    ) -> Result<BacktestResult, BacktestError> {
926        let engine = BacktestEngine::new(config.clone());
927        let mut combined_equity: Vec<EquityPoint> = per_symbol_equity
928            .values()
929            .flatten()
930            .cloned()
931            .collect();
932        combined_equity.extend(portfolio_equity.clone());
933
934        let initial_cash = match &config.execution_model {
935            ExecutionModel::Simple(cm) => cm.initial_cash,
936            _ => 100_000.0,
937        };
938        let portfolio_final = portfolio_equity
939            .last()
940            .map(|e| e.equity)
941            .unwrap_or(initial_cash);
942        let total_return = (portfolio_final - initial_cash) / initial_cash;
943        let num_trades = trades.len() as f64;
944        let n_symbols = per_symbol_equity.len() as f64;
945
946        let mut stats = HashMap::new();
947        stats.insert("initial_cash".to_string(), initial_cash);
948        stats.insert("final_equity".to_string(), portfolio_final);
949        stats.insert("total_return".to_string(), total_return);
950        stats.insert("num_trades".to_string(), num_trades);
951        stats.insert("net_pnl".to_string(), portfolio_final - initial_cash);
952        stats.insert("num_symbols".to_string(), n_symbols);
953        stats.insert("portfolio_mode".to_string(), 1.0); // 1.0 = shared_capital sentinel
954
955        Ok(BacktestResult {
956            trades: engine.trades_to_df(&trades, true)?,
957            equity_curve: engine.equity_to_df(&combined_equity, true)?,
958            stats,
959        })
960    }
961
962    fn per_symbol_initial_cash(&self) -> f64 {
963        match &self.config.execution_model {
964            ExecutionModel::Simple(cm) => cm.initial_cash,
965            _ => 100_000.0,
966        }
967    }
968
969    fn simulate_dataframe(
970        &self,
971        df: &DataFrame,
972        symbol: Option<&str>,
973    ) -> Result<(Vec<Trade>, Vec<EquityPoint>), BacktestError> {
974        let ts_col = &self.config.timestamp_col;
975        let close_col = &self.config.close_col;
976        let sig_col = &self.config.signal_col;
977
978        let ts_series = df.column(ts_col)?.clone();
979        let close_ca = df.column(close_col)?.f64()?.clone();
980        let (signal_vals, signal_metas) = self.load_signals(df, sig_col)?;
981
982        let entry_filters = self.load_entry_filters(df)?;
983        let size_multipliers = self.load_size_multipliers(df)?;
984
985        let n = signal_vals.len();
986        if let Some(ref f) = entry_filters {
987            if f.len() != n {
988                return Err(BacktestError::InvalidInput(
989                    "entry_filter column length mismatch".into(),
990                ));
991            }
992        }
993        if let Some(ref m) = size_multipliers {
994            if m.len() != n {
995                return Err(BacktestError::InvalidInput(
996                    "size_multiplier column length mismatch".into(),
997                ));
998            }
999        }
1000
1001        let effective_signals: Vec<f64> = signal_vals
1002            .iter()
1003            .enumerate()
1004            .map(|(i, &raw)| {
1005                apply_signal_modifiers(
1006                    raw,
1007                    entry_filters.as_ref().map(|f| f[i]),
1008                    size_multipliers.as_ref().map(|m| m[i]),
1009                )
1010            })
1011            .collect();
1012
1013        let timestamps = self.extract_timestamps(&ts_series)?;
1014        let closes: Vec<f64> = close_ca
1015            .into_iter()
1016            .map(|v| v.unwrap_or(f64::NAN))
1017            .collect();
1018
1019        if timestamps.len() != closes.len() || closes.len() != effective_signals.len() {
1020            return Err(BacktestError::InvalidInput("column length mismatch".into()));
1021        }
1022
1023        let exec = &self.config.execution_model;
1024        let sizer = &self.config.position_sizer;
1025        let mut effective_metas: Vec<Option<HashMap<String, f64>>> =
1026            Vec::with_capacity(effective_signals.len());
1027        for (i, &raw) in effective_signals.iter().enumerate() {
1028            if raw == 0.0 {
1029                effective_metas.push(None);
1030            } else {
1031                effective_metas.push(signal_metas.get(i).cloned().flatten());
1032            }
1033        }
1034        let (highs, lows) = self.load_ohlc_columns(df)?;
1035        let delay = self.config.execution_delay;
1036        let stops = &self.config.stop_config;
1037        let (mut trades, mut equity_points) = run_simulation(
1038            &timestamps,
1039            &closes,
1040            highs.as_deref(),
1041            lows.as_deref(),
1042            |i| (effective_signals[i], effective_metas[i].clone()),
1043            exec,
1044            sizer,
1045            delay,
1046            stops,
1047        );
1048
1049        if let Some(sym) = symbol {
1050            let sym_owned = sym.to_string();
1051            for t in &mut trades {
1052                t.symbol = Some(sym_owned.clone());
1053            }
1054            for e in &mut equity_points {
1055                e.symbol = Some(sym_owned.clone());
1056            }
1057        }
1058
1059        Ok((trades, equity_points))
1060    }
1061
1062    fn load_signals(
1063        &self,
1064        df: &DataFrame,
1065        sig_col: &str,
1066    ) -> Result<(Vec<f64>, Vec<Option<HashMap<String, f64>>>), BacktestError> {
1067        let signal_series = df.column(sig_col)?;
1068        let s = signal_series
1069            .as_series()
1070            .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1071
1072        if s.dtype().is_struct() {
1073            let ca = s.struct_().map_err(|e| BacktestError::Polars(e))?;
1074            let n = ca.len();
1075            let mut exposures = Vec::with_capacity(n);
1076            let mut metas = Vec::with_capacity(n);
1077            for i in 0..n {
1078                let (exp, meta) = parse_struct_signal_row(ca, i)?;
1079                exposures.push(exp);
1080                metas.push(meta);
1081            }
1082            return Ok((exposures, metas));
1083        }
1084
1085        let signal_vals: Vec<f64> = if signal_series.dtype().is_bool() {
1086            signal_series
1087                .bool()?
1088                .into_iter()
1089                .map(|b| if b.unwrap_or(false) { 1.0 } else { 0.0 })
1090                .collect()
1091        } else {
1092            signal_series
1093                .f64()?
1094                .into_iter()
1095                .map(|v| v.unwrap_or(0.0))
1096                .collect()
1097        };
1098        let metas = vec![None; signal_vals.len()];
1099        Ok((signal_vals, metas))
1100    }
1101
1102    fn load_entry_filters(&self, df: &DataFrame) -> Result<Option<Vec<bool>>, BacktestError> {
1103        let Some(col_name) = &self.config.entry_filter_col else {
1104            return Ok(None);
1105        };
1106        if df.column(col_name).is_err() {
1107            return Err(BacktestError::InvalidInput(format!(
1108                "missing column: {}",
1109                col_name
1110            )));
1111        }
1112        extract_bool_column(df.column(col_name)?.clone())
1113            .map(Some)
1114    }
1115
1116    fn load_size_multipliers(&self, df: &DataFrame) -> Result<Option<Vec<f64>>, BacktestError> {
1117        let Some(col_name) = &self.config.size_multiplier_col else {
1118            return Ok(None);
1119        };
1120        if df.column(col_name).is_err() {
1121            return Err(BacktestError::InvalidInput(format!(
1122                "missing column: {}",
1123                col_name
1124            )));
1125        }
1126        extract_f64_column(df.column(col_name)?.clone())
1127            .map(Some)
1128    }
1129
1130    /// Load optional high/low columns when OHLC touched-exit is enabled.
1131    fn load_ohlc_columns(
1132        &self,
1133        df: &DataFrame,
1134    ) -> Result<(Option<Vec<f64>>, Option<Vec<f64>>), BacktestError> {
1135        if self.config.stop_config.stop_evaluation != StopEvaluationMode::OhlcTouched {
1136            return Ok((None, None));
1137        }
1138        let high_col = self.config.high_col.as_ref().ok_or_else(|| {
1139            BacktestError::InvalidInput(
1140                "OhlcTouched stop evaluation requires high_col".into(),
1141            )
1142        })?;
1143        let low_col = self.config.low_col.as_ref().ok_or_else(|| {
1144            BacktestError::InvalidInput(
1145                "OhlcTouched stop evaluation requires low_col".into(),
1146            )
1147        })?;
1148        if df.column(high_col).is_err() {
1149            return Err(BacktestError::InvalidInput(format!(
1150                "missing column: {}",
1151                high_col
1152            )));
1153        }
1154        if df.column(low_col).is_err() {
1155            return Err(BacktestError::InvalidInput(format!(
1156                "missing column: {}",
1157                low_col
1158            )));
1159        }
1160        let highs = extract_f64_column(df.column(high_col)?.clone())?;
1161        let lows = extract_f64_column(df.column(low_col)?.clone())?;
1162        Ok((Some(highs), Some(lows)))
1163    }
1164
1165    fn extract_timestamps(&self, col: &Column) -> Result<Vec<DateTime<Utc>>, BacktestError> {
1166        // Support Datetime, Int64 (as unix micros or simple increasing), or fallback.
1167        // In Polars 0.46+, df.column() yields Column; convert for ChunkedArray access.
1168        let s = col
1169            .as_series()
1170            .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1171
1172        // Support Datetime, Int64 (as unix micros or simple increasing), or fallback
1173        if let Ok(ca) = s.datetime() {
1174            return Ok(ca
1175                .into_iter()
1176                .map(|opt| {
1177                    opt.map(|v| {
1178                        // Polars Datetime usually stored as ms since epoch
1179                        let secs = v / 1000;
1180                        let nanos = ((v % 1000) * 1_000_000) as u32;
1181                        DateTime::<Utc>::from_timestamp(secs, nanos).unwrap_or_else(Utc::now)
1182                    })
1183                    .unwrap_or_else(Utc::now)
1184                })
1185                .collect());
1186        }
1187
1188        if let Ok(ca) = s.i64() {
1189            // Treat as increasing bar index or unix seconds for synth tests
1190            return Ok(ca
1191                .into_iter()
1192                .enumerate()
1193                .map(|(i, opt)| {
1194                    let v = opt.unwrap_or(i as i64);
1195                    DateTime::<Utc>::from_timestamp(v, 0).unwrap_or_else(Utc::now)
1196                })
1197                .collect());
1198        }
1199
1200        // Fallback: treat as strings or error for MVP
1201        Err(BacktestError::InvalidInput(
1202            "timestamp column must be Datetime or Int64 for this MVP".into(),
1203        ))
1204    }
1205
1206    fn trades_to_df(&self, trades: &[Trade], include_symbol: bool) -> Result<DataFrame, PolarsError> {
1207        if trades.is_empty() {
1208            let mut cols = vec![
1209                Column::new("trade_id".into(), Vec::<u32>::new()),
1210                Column::new("side".into(), Vec::<i8>::new()),
1211                Column::new("entry_ts".into(), Vec::<i64>::new()),
1212                Column::new("entry_price".into(), Vec::<f64>::new()),
1213                Column::new("entry_fill_price".into(), Vec::<f64>::new()),
1214                Column::new("exit_ts".into(), Vec::<Option<i64>>::new()),
1215                Column::new("exit_price".into(), Vec::<Option<f64>>::new()),
1216                Column::new("exit_fill_price".into(), Vec::<Option<f64>>::new()),
1217                Column::new("quantity".into(), Vec::<f64>::new()),
1218                Column::new("pnl_net".into(), Vec::<f64>::new()),
1219            ];
1220            if include_symbol {
1221                cols.push(Column::new("symbol".into(), Vec::<Option<String>>::new()));
1222            }
1223            return Ok(DataFrame::new(cols)?);
1224        }
1225
1226        let ids: Vec<u32> = trades.iter().map(|t| t.trade_id).collect();
1227        let sides: Vec<i8> = trades.iter().map(|t| t.side).collect();
1228        let entry_ts: Vec<i64> = trades.iter().map(|t| t.entry_ts.timestamp()).collect();
1229        let entry_px: Vec<f64> = trades.iter().map(|t| t.entry_price).collect();
1230        let entry_fill_px: Vec<f64> = trades.iter().map(|t| t.entry_fill_price).collect();
1231        let exit_ts: Vec<Option<i64>> = trades
1232            .iter()
1233            .map(|t| t.exit_ts.map(|d| d.timestamp()))
1234            .collect();
1235        let exit_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_price).collect();
1236        let exit_fill_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_fill_price).collect();
1237        let qty: Vec<f64> = trades.iter().map(|t| t.quantity).collect();
1238        let pnl: Vec<f64> = trades.iter().map(|t| t.pnl_net).collect();
1239
1240        let mut cols = vec![
1241            Column::new("trade_id".into(), ids),
1242            Column::new("side".into(), sides),
1243            Column::new("entry_ts".into(), entry_ts),
1244            Column::new("entry_price".into(), entry_px),
1245            Column::new("entry_fill_price".into(), entry_fill_px),
1246            Column::new("exit_ts".into(), exit_ts),
1247            Column::new("exit_price".into(), exit_px),
1248            Column::new("exit_fill_price".into(), exit_fill_px),
1249            Column::new("quantity".into(), qty),
1250            Column::new("pnl_net".into(), pnl),
1251        ];
1252        if include_symbol {
1253            let symbols: Vec<Option<String>> = trades.iter().map(|t| t.symbol.clone()).collect();
1254            cols.push(Column::new("symbol".into(), symbols));
1255        }
1256
1257        DataFrame::new(cols)
1258    }
1259
1260    fn equity_to_df(&self, points: &[EquityPoint], include_symbol: bool) -> Result<DataFrame, PolarsError> {
1261        if points.is_empty() {
1262            let mut cols = vec![
1263                Column::new("ts".into(), Vec::<i64>::new()),
1264                Column::new("equity".into(), Vec::<f64>::new()),
1265                Column::new("cash".into(), Vec::<f64>::new()),
1266                Column::new("position".into(), Vec::<f64>::new()),
1267                Column::new("close".into(), Vec::<f64>::new()),
1268            ];
1269            if include_symbol {
1270                cols.push(Column::new("symbol".into(), Vec::<Option<String>>::new()));
1271            }
1272            return Ok(DataFrame::new(cols)?);
1273        }
1274
1275        let ts: Vec<i64> = points.iter().map(|p| p.ts.timestamp()).collect();
1276        let eq: Vec<f64> = points.iter().map(|p| p.equity).collect();
1277        let pos: Vec<f64> = points.iter().map(|p| p.position).collect();
1278        let cash: Vec<f64> = points.iter().map(|p| p.cash).collect();
1279        let close: Vec<f64> = points.iter().map(|p| p.close).collect();
1280
1281        let mut cols = vec![
1282            Column::new("ts".into(), ts),
1283            Column::new("equity".into(), eq),
1284            Column::new("cash".into(), cash),
1285            Column::new("position".into(), pos),
1286            Column::new("close".into(), close),
1287        ];
1288        if include_symbol {
1289            let symbols: Vec<Option<String>> = points.iter().map(|p| p.symbol.clone()).collect();
1290            cols.push(Column::new("symbol".into(), symbols));
1291        }
1292
1293        DataFrame::new(cols)
1294    }
1295}
1296
1297/// Apply optional entry filter (false → flat) and size multiplier to a raw signal.
1298/// Shared semantics for batch `run()` and streaming parity tests (quantwave-cr6v.3).
1299pub fn apply_signal_modifiers(
1300    raw_signal: f64,
1301    entry_filter: Option<bool>,
1302    size_multiplier: Option<f64>,
1303) -> f64 {
1304    if matches!(entry_filter, Some(false)) {
1305        return 0.0;
1306    }
1307    let mut exposure = raw_signal;
1308    if let Some(m) = size_multiplier {
1309        exposure *= m;
1310    }
1311    if exposure.is_finite() && exposure != 0.0 {
1312        exposure
1313    } else {
1314        0.0
1315    }
1316}
1317
1318fn extract_bool_column(col: Column) -> Result<Vec<bool>, BacktestError> {
1319    let s = col
1320        .as_series()
1321        .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1322    if let Ok(ca) = s.bool() {
1323        return Ok(ca
1324            .into_iter()
1325            .map(|opt| opt.unwrap_or(false))
1326            .collect());
1327    }
1328    Err(BacktestError::InvalidInput(
1329        "entry_filter column must be boolean".into(),
1330    ))
1331}
1332
1333fn extract_f64_column(col: Column) -> Result<Vec<f64>, BacktestError> {
1334    let s = col
1335        .as_series()
1336        .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1337    if let Ok(ca) = s.f64() {
1338        return Ok(ca.into_iter().map(|opt| opt.unwrap_or(0.0)).collect());
1339    }
1340    Err(BacktestError::InvalidInput(
1341        "size_multiplier column must be f64".into(),
1342    ))
1343}
1344
1345fn extract_string_column(col: Column) -> Result<Vec<String>, BacktestError> {
1346    let s = col
1347        .as_series()
1348        .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1349    if let Ok(ca) = s.str() {
1350        return Ok(ca
1351            .into_iter()
1352            .map(|opt| opt.unwrap_or_default().to_string())
1353            .collect());
1354    }
1355    Err(BacktestError::InvalidInput(
1356        "symbol column must be Utf8/String".into(),
1357    ))
1358}
1359
1360fn validate_sorted_timestamp_symbol(
1361    timestamps: &[DateTime<Utc>],
1362    symbols: &[String],
1363) -> Result<(), BacktestError> {
1364    if timestamps.len() != symbols.len() {
1365        return Err(BacktestError::InvalidInput("column length mismatch".into()));
1366    }
1367    for i in 1..timestamps.len() {
1368        let prev = (&timestamps[i - 1], &symbols[i - 1]);
1369        let curr = (&timestamps[i], &symbols[i]);
1370        if curr < prev {
1371            return Err(BacktestError::UnsortedData);
1372        }
1373    }
1374    Ok(())
1375}
1376
1377fn aggregate_portfolio_equity(per_symbol: &HashMap<String, Vec<EquityPoint>>) -> Vec<EquityPoint> {
1378    use std::collections::BTreeSet;
1379
1380    let mut ts_set = BTreeSet::new();
1381    for points in per_symbol.values() {
1382        for p in points {
1383            ts_set.insert(p.ts);
1384        }
1385    }
1386
1387    ts_set
1388        .into_iter()
1389        .map(|ts| {
1390            let mut total_equity = 0.0;
1391            let mut total_cash = 0.0;
1392            let mut total_position = 0.0;
1393            for points in per_symbol.values() {
1394                if let Some(p) = points.iter().find(|p| p.ts == ts) {
1395                    total_equity += p.equity;
1396                    total_cash += p.cash;
1397                    total_position += p.position;
1398                }
1399            }
1400            EquityPoint {
1401                ts,
1402                symbol: None,
1403                equity: total_equity,
1404                cash: total_cash,
1405                position: total_position,
1406                close: 0.0,
1407            }
1408        })
1409        .collect()
1410}
1411
1412/// Convenience function for the most common "simple boolean signal" use case
1413/// on synthetic or small data (exactly as required for quantwave-1hr MVP).
1414pub fn backtest_simple_bool_signal(
1415    ohlcv: DataFrame,
1416    signal_col: &str,
1417) -> Result<BacktestResult, BacktestError> {
1418    let config = BacktestConfig {
1419        signal_col: signal_col.to_string(),
1420        ..Default::default()
1421    };
1422    let engine = BacktestEngine::new(config);
1423    engine.run(ohlcv.lazy())
1424}
1425
1426/// Shared causal simulation core (the single source of truth for execution).
1427/// Used by both batch (scalar exposures) and streaming (Next-driven) paths to
1428/// guarantee parity on equity, trades, and stats for the same signal sequence.
1429/// Generalized for variable `exposure` (sizing) + optional per-bar metadata.
1430///
1431/// Signed exposure: `>0` long units, `<0` short units, `0` flat. Discrete entry/exit
1432/// and long↔short flips (close then open same bar). No intra-trade resizing.
1433fn run_simulation(
1434    timestamps: &[DateTime<Utc>],
1435    closes: &[f64],
1436    highs: Option<&[f64]>,
1437    lows: Option<&[f64]>,
1438    mut next_signal: impl FnMut(usize) -> (f64, Option<HashMap<String, f64>>),
1439    exec: &ExecutionModel,
1440    sizer: &Option<InitialRiskPositionSizer>,
1441    execution_delay: ExecutionDelay,
1442    stop_config: &StopConfig,
1443) -> (Vec<Trade>, Vec<EquityPoint>) {
1444    use stops::{evaluate_stops, trailing_level_at_entry, OhlcBar, StopPositionState};
1445    let mut cash = match exec {
1446        ExecutionModel::Simple(cm) => cm.initial_cash,
1447        ExecutionModel::HighFidelity { .. } => 100_000.0,
1448    };
1449    let mut current_exposure: f64 = 0.0;
1450    let mut entry_price: f64 = 0.0;
1451    let mut entry_ts: Option<DateTime<Utc>> = None;
1452    let mut entry_metadata: Option<HashMap<String, f64>> = None;
1453    let mut stop_state = StopPositionState::default();
1454    let mut need_signal_reset = false;
1455    let mut trade_id: u32 = 0;
1456    let mut trades: Vec<Trade> = Vec::new();
1457    let mut equity_points: Vec<EquityPoint> = Vec::with_capacity(closes.len());
1458
1459    let mut record_position_exit =
1460        |cash: &mut f64,
1461         tid: u32,
1462         side: i8,
1463         qty: f64,
1464         entry_px: f64,
1465         ets: DateTime<Utc>,
1466         exit_bar: usize,
1467         exit_raw_price: f64,
1468         meta: Option<HashMap<String, f64>>| {
1469            // Long exit = sell (is_buy false); short cover = buy (is_buy true).
1470            let is_buy = side == -1;
1471            let fill_price = exec.slippage_price(exit_raw_price, qty, is_buy, None);
1472            let notional = fill_price * qty;
1473            let cost = exec.commission_for(qty, fill_price);
1474            let gross_pnl = if side == 1 {
1475                (fill_price - entry_px) * qty
1476            } else {
1477                (entry_px - fill_price) * qty
1478            };
1479            let net_pnl = gross_pnl - cost;
1480            if side == 1 {
1481                *cash += notional - cost;
1482            } else {
1483                *cash -= notional + cost;
1484            }
1485            trades.push(Trade {
1486                trade_id: tid,
1487                symbol: None,
1488                side,
1489                entry_ts: ets,
1490                entry_price: entry_px,
1491                entry_fill_price: entry_px,
1492                exit_ts: Some(timestamps[exit_bar]),
1493                exit_price: Some(exit_raw_price),
1494                exit_fill_price: Some(fill_price),
1495                pnl_gross: gross_pnl,
1496                costs: cost,
1497                pnl_net: net_pnl,
1498                quantity: qty,
1499                entry_metadata: meta,
1500            });
1501        };
1502
1503    let open_position = |cash: &mut f64,
1504                             tid: u32,
1505                             desired: f64,
1506                             fill_bar: usize,
1507                             meta: Option<HashMap<String, f64>>|
1508     -> (u32, f64, f64, Option<DateTime<Utc>>, Option<HashMap<String, f64>>, Option<f64>) {
1509        let qty = desired.abs();
1510        let is_long = desired > 0.0;
1511        let is_buy = is_long;
1512        let close = closes[fill_bar];
1513        let fill_price = exec.slippage_price(close, qty, is_buy, None);
1514        let notional = fill_price * qty;
1515        let cost = exec.commission_for(qty, fill_price);
1516        if is_long {
1517            *cash -= notional + cost;
1518        } else {
1519            *cash += notional - cost;
1520        }
1521        let new_tid = tid + 1;
1522        let exposure = if is_long { qty } else { -qty };
1523        let trail = stop_config.trailing_stop_pct.map(|pct| {
1524            trailing_level_at_entry(fill_price, is_long, pct)
1525        });
1526        (
1527            new_tid,
1528            exposure,
1529            fill_price,
1530            Some(timestamps[fill_bar]),
1531            meta,
1532            trail,
1533        )
1534    };
1535
1536    for i in 0..closes.len() {
1537        let close = closes[i];
1538        let ohlc = OhlcBar {
1539            close,
1540            high: highs.and_then(|h| h.get(i).copied()),
1541            low: lows.and_then(|l| l.get(i).copied()),
1542        };
1543        if !close.is_finite() {
1544            let equity = cash + current_exposure * close;
1545            equity_points.push(EquityPoint {
1546                ts: timestamps[i],
1547                symbol: None,
1548                equity,
1549                cash,
1550                position: current_exposure,
1551                close,
1552            });
1553            continue;
1554        }
1555
1556        // Stop / target checks while in position (before signal-driven entry).
1557        if current_exposure != 0.0 && stop_config.has_stops() {
1558            let is_long = current_exposure > 0.0;
1559            let qty = current_exposure.abs();
1560            if let Some(stop_exit) =
1561                evaluate_stops(stop_config, ohlc, is_long, entry_price, &mut stop_state)
1562            {
1563                if let Some(ets) = entry_ts.take() {
1564                    let side = if is_long { 1 } else { -1 };
1565                    record_position_exit(
1566                        &mut cash,
1567                        trade_id,
1568                        side,
1569                        qty,
1570                        entry_price,
1571                        ets,
1572                        i,
1573                        stop_exit.exit_price,
1574                        entry_metadata.clone(),
1575                    );
1576                    current_exposure = 0.0;
1577                    entry_price = 0.0;
1578                    stop_state = StopPositionState::default();
1579                    entry_metadata = None;
1580                    need_signal_reset = true;
1581                }
1582            }
1583        }
1584
1585        let (raw_exposure, meta) = match signal_bar_index(i, execution_delay) {
1586            Some(si) => next_signal(si),
1587            None => (0.0, None),
1588        };
1589        // Apply rich sizer if configured (n1yc.1) using current equity for % calc
1590        let current_equity = cash + current_exposure * close;
1591        let desired_exposure = if let Some(s) = sizer {
1592            s.compute_sized_exposure(raw_exposure, &meta, close, current_equity)
1593        } else {
1594            raw_exposure
1595        };
1596        let desired = if desired_exposure.is_finite() && desired_exposure != 0.0 {
1597            desired_exposure
1598        } else {
1599            0.0
1600        };
1601
1602        if desired == 0.0 {
1603            need_signal_reset = false;
1604        }
1605
1606        let currently_in = current_exposure != 0.0;
1607
1608        if desired == 0.0 && currently_in {
1609            if let Some(ets) = entry_ts.take() {
1610                let side = if current_exposure > 0.0 { 1 } else { -1 };
1611                record_position_exit(
1612                    &mut cash,
1613                    trade_id,
1614                    side,
1615                    current_exposure.abs(),
1616                    entry_price,
1617                    ets,
1618                    i,
1619                    close,
1620                    meta.clone(),
1621                );
1622                current_exposure = 0.0;
1623                entry_price = 0.0;
1624                stop_state = StopPositionState::default();
1625                entry_metadata = None;
1626            }
1627        } else if desired != 0.0 && !need_signal_reset {
1628            let want_long = desired > 0.0;
1629            let in_long = current_exposure > 0.0;
1630            let in_short = current_exposure < 0.0;
1631            let flip = (want_long && in_short) || (!want_long && in_long);
1632
1633            if flip {
1634                if let Some(ets) = entry_ts.take() {
1635                    let side = if in_long { 1 } else { -1 };
1636                    record_position_exit(
1637                        &mut cash,
1638                        trade_id,
1639                        side,
1640                        current_exposure.abs(),
1641                        entry_price,
1642                        ets,
1643                        i,
1644                        close,
1645                        entry_metadata.clone(),
1646                    );
1647                    current_exposure = 0.0;
1648                    entry_price = 0.0;
1649                    stop_state = StopPositionState::default();
1650                    entry_metadata = None;
1651                }
1652            }
1653
1654            if current_exposure == 0.0 {
1655                let (new_tid, exp, ep, ets, em, trail) =
1656                    open_position(&mut cash, trade_id, desired, i, meta.clone());
1657                trade_id = new_tid;
1658                current_exposure = exp;
1659                entry_price = ep;
1660                entry_ts = ets;
1661                entry_metadata = em;
1662                stop_state.trailing_stop_level = trail;
1663            }
1664        }
1665
1666        let equity = cash + current_exposure * close;
1667        equity_points.push(EquityPoint {
1668            ts: timestamps[i],
1669            symbol: None,
1670            equity,
1671            cash,
1672            position: current_exposure,
1673            close,
1674        });
1675    }
1676
1677    // Close any open position at last bar (terminal MTM, no extra cost)
1678    if current_exposure != 0.0 {
1679        let last_close = *closes.last().unwrap();
1680        let qty = current_exposure.abs();
1681        let side = if current_exposure > 0.0 { 1 } else { -1 };
1682        let gross = if side == 1 {
1683            (last_close - entry_price) * qty
1684        } else {
1685            (entry_price - last_close) * qty
1686        };
1687        if let Some(ets) = entry_ts {
1688            trades.push(Trade {
1689                trade_id,
1690                symbol: None,
1691                side,
1692                entry_ts: ets,
1693                entry_price,
1694                entry_fill_price: entry_price,
1695                exit_ts: None,
1696                exit_price: Some(last_close),
1697                exit_fill_price: None,
1698                pnl_gross: gross,
1699                costs: 0.0,
1700                pnl_net: gross,
1701                quantity: qty,
1702                entry_metadata: None,
1703            });
1704        }
1705    }
1706
1707    (trades, equity_points)
1708}
1709
1710/// Run simulation in streaming mode driven by a Next<T> signal generator.
1711/// The generator receives `&Bar` each step (price + ts) and returns `StrategySignal`
1712/// (exposure for sizing + rich metadata e.g. pole_height).
1713///
1714/// This + the batch path + shared `run_simulation` core = the parity framework
1715/// for quantwave-ug9t. Use fresh generator instances for each run in tests.
1716pub fn run_streaming_simulation<G>(
1717    bars: &[Bar],
1718    mut generator: G,
1719    config: BacktestConfig,
1720) -> Result<BacktestResult, BacktestError>
1721where
1722    G: for<'a> Next<&'a Bar, Output = StrategySignal>,
1723{
1724    if bars.is_empty() {
1725        return Err(BacktestError::InvalidInput("empty bars".into()));
1726    }
1727
1728    let timestamps: Vec<DateTime<Utc>> = bars.iter().map(|b| b.ts).collect();
1729    let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
1730    let highs: Vec<f64> = bars.iter().map(|b| b.high.unwrap_or(b.close)).collect();
1731    let lows: Vec<f64> = bars.iter().map(|b| b.low.unwrap_or(b.close)).collect();
1732    let use_ohlc = config.stop_config.stop_evaluation == StopEvaluationMode::OhlcTouched;
1733
1734    let exec = &config.execution_model;
1735    let sizer = &config.position_sizer;
1736
1737    let delay = config.execution_delay;
1738    let stops = &config.stop_config;
1739    let (trades, equity_points) = run_simulation(
1740        &timestamps,
1741        &closes,
1742        if use_ohlc {
1743            Some(highs.as_slice())
1744        } else {
1745            None
1746        },
1747        if use_ohlc {
1748            Some(lows.as_slice())
1749        } else {
1750            None
1751        },
1752        |i| {
1753            let sig = generator.next(&bars[i]);
1754            (sig.exposure, sig.metadata.clone())
1755        },
1756        exec,
1757        sizer,
1758        delay,
1759        stops,
1760    );
1761
1762    // Build Polars (same as batch)
1763    // Note: we don't have self here; replicate minimal DF build (trades/equity use free fns?).
1764    // For simplicity duplicate small builders or make private fns pub(crate).
1765    // Here we inline minimal (copy of logic, acceptable for thin crate).
1766    let trades_df = if trades.is_empty() {
1767        DataFrame::new(vec![
1768            Column::new("trade_id".into(), Vec::<u32>::new()),
1769            Column::new("side".into(), Vec::<i8>::new()),
1770            Column::new("entry_ts".into(), Vec::<i64>::new()),
1771            Column::new("entry_price".into(), Vec::<f64>::new()),
1772            Column::new("pnl_net".into(), Vec::<f64>::new()),
1773        ])?
1774    } else {
1775        let ids: Vec<u32> = trades.iter().map(|t| t.trade_id).collect();
1776        let sides: Vec<i8> = trades.iter().map(|t| t.side).collect();
1777        let entry_ts: Vec<i64> = trades.iter().map(|t| t.entry_ts.timestamp()).collect();
1778        let entry_px: Vec<f64> = trades.iter().map(|t| t.entry_price).collect();
1779        let exit_ts: Vec<Option<i64>> = trades
1780            .iter()
1781            .map(|t| t.exit_ts.map(|d| d.timestamp()))
1782            .collect();
1783        let exit_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_price).collect();
1784        let pnl: Vec<f64> = trades.iter().map(|t| t.pnl_net).collect();
1785
1786        DataFrame::new(vec![
1787            Column::new("trade_id".into(), ids),
1788            Column::new("side".into(), sides),
1789            Column::new("entry_ts".into(), entry_ts),
1790            Column::new("entry_price".into(), entry_px),
1791            Column::new("exit_ts".into(), exit_ts),
1792            Column::new("exit_price".into(), exit_px),
1793            Column::new("pnl_net".into(), pnl),
1794        ])?
1795    };
1796
1797    let equity_df = if equity_points.is_empty() {
1798        DataFrame::new(vec![
1799            Column::new("ts".into(), Vec::<i64>::new()),
1800            Column::new("equity".into(), Vec::<f64>::new()),
1801            Column::new("position".into(), Vec::<f64>::new()),
1802        ])?
1803    } else {
1804        let ts: Vec<i64> = equity_points.iter().map(|p| p.ts.timestamp()).collect();
1805        let eq: Vec<f64> = equity_points.iter().map(|p| p.equity).collect();
1806        let pos: Vec<f64> = equity_points.iter().map(|p| p.position).collect();
1807        let cash: Vec<f64> = equity_points.iter().map(|p| p.cash).collect();
1808        let close: Vec<f64> = equity_points.iter().map(|p| p.close).collect();
1809
1810        DataFrame::new(vec![
1811            Column::new("ts".into(), ts),
1812            Column::new("equity".into(), eq),
1813            Column::new("cash".into(), cash),
1814            Column::new("position".into(), pos),
1815            Column::new("close".into(), close),
1816        ])?
1817    };
1818
1819    let initial_cash = match &config.execution_model {
1820        ExecutionModel::Simple(cm) => cm.initial_cash,
1821        _ => 100_000.0,
1822    };
1823    let final_equity = equity_points
1824        .last()
1825        .map(|e| e.equity)
1826        .unwrap_or(initial_cash);
1827    let total_return = (final_equity - initial_cash) / initial_cash;
1828    let num_trades = trades.len() as f64;
1829
1830    let mut stats = HashMap::new();
1831    stats.insert("initial_cash".to_string(), initial_cash);
1832    stats.insert("final_equity".to_string(), final_equity);
1833    stats.insert("total_return".to_string(), total_return);
1834    stats.insert("num_trades".to_string(), num_trades);
1835    stats.insert("net_pnl".to_string(), final_equity - initial_cash);
1836
1837    Ok(BacktestResult {
1838        trades: trades_df,
1839        equity_curve: equity_df,
1840        stats,
1841    })
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847    use approx::assert_relative_eq;
1848    // use polars::prelude::*;
1849    use rand::Rng;
1850    // Core types needed for ug9t parity strategy (regime + feature + rich PA)
1851    use quantwave_core::features::CyberCycleFeatureExtractor;
1852    use quantwave_core::regimes::MarketRegime;
1853    use quantwave_core::regimes::tar::TAR;
1854    use quantwave_core::traits::Next;
1855    use std::collections::HashMap;
1856
1857    #[test]
1858    fn test_basic_long_only_flip_on_synthetic() {
1859        // Synthetic 6 bars. Signal goes 0 -> 1 (enter) -> 1 -> 0 (exit).
1860        // Prices rise then fall. With small costs, net should be positive on the move.
1861        let n: usize = 6;
1862        let timestamps: Vec<i64> = (0..n)
1863            .map(|i| 1_700_000_000i64 + (i as i64) * 3600)
1864            .collect(); // unix secs
1865        let closes = vec![100.0, 101.0, 102.5, 103.0, 102.0, 101.0];
1866        let signals = vec![0.0, 1.0, 1.0, 1.0, 0.0, 0.0];
1867
1868        let df = DataFrame::new(vec![
1869            Column::new("timestamp".into(), timestamps),
1870            Column::new("close".into(), closes.clone()),
1871            Column::new("signal".into(), signals),
1872        ])
1873        .unwrap();
1874
1875        let result = backtest_simple_bool_signal(df, "signal").expect("sim should succeed");
1876
1877        // 1 trade should be generated (closed on signal drop)
1878        assert_eq!(result.trades.height(), 1);
1879        let num_trades: f64 = *result.stats.get("num_trades").unwrap();
1880        assert_relative_eq!(num_trades, 1.0, epsilon = 1e-9);
1881
1882        // Final equity > initial because price rose while long
1883        let final_eq = *result.stats.get("final_equity").unwrap();
1884        let init = 100_000.0;
1885        assert!(
1886            final_eq > init,
1887            "equity should grow on winning long: {} vs {}",
1888            final_eq,
1889            init
1890        );
1891
1892        // Equity curve has exactly n rows
1893        assert_eq!(result.equity_curve.height(), n);
1894
1895        // Spot check: last equity point should reflect closed position
1896        let last_equity = result
1897            .equity_curve
1898            .column("equity")
1899            .unwrap()
1900            .f64()
1901            .unwrap()
1902            .get(n - 1)
1903            .unwrap();
1904        assert_relative_eq!(last_equity, final_eq, epsilon = 1e-6);
1905    }
1906
1907    #[test]
1908    fn test_flat_always_signal_produces_no_trades_and_flat_equity() {
1909        let n: usize = 5;
1910        let ts: Vec<i64> = (0..n).map(|i| 1_700_000_100 + i as i64).collect();
1911        let closes = vec![100.0; n];
1912        let signals = vec![0.0; n];
1913
1914        let df = DataFrame::new(vec![
1915            Column::new("timestamp".into(), ts),
1916            Column::new("close".into(), closes),
1917            Column::new("signal".into(), signals),
1918        ])
1919        .unwrap();
1920
1921        let result = backtest_simple_bool_signal(df, "signal").unwrap();
1922
1923        assert_eq!(result.trades.height(), 0);
1924        let num = *result.stats.get("num_trades").unwrap();
1925        assert_relative_eq!(num, 0.0, epsilon = 1e-9);
1926
1927        // Equity should stay at initial (minus tiny floating error)
1928        let final_equity_val = *result.stats.get("final_equity").unwrap();
1929        assert_relative_eq!(final_equity_val, 100_000.0, epsilon = 1e-4);
1930    }
1931
1932    #[test]
1933    fn test_synthetic_with_small_random_walk_and_bool_signal_matches_manual_calc() {
1934        // Tiny manual parity check: build expected equity manually for one known path.
1935        let mut rng = rand::thread_rng();
1936        let n: usize = 8;
1937        let mut price = 100.0_f64;
1938        let mut closes = Vec::with_capacity(n);
1939        let signals = vec![0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0]; // enter on bar 1, exit on bar 5
1940        let mut ts = Vec::with_capacity(n);
1941
1942        for i in 0..n {
1943            ts.push(1_700_000_200 + i as i64);
1944            closes.push(price);
1945            price += rng.gen_range(-0.8..1.2);
1946        }
1947
1948        let df = DataFrame::new(vec![
1949            Column::new("timestamp".into(), ts.clone()),
1950            Column::new("close".into(), closes.clone()),
1951            Column::new("signal".into(), signals.clone()),
1952        ])
1953        .unwrap();
1954
1955        let result = backtest_simple_bool_signal(df.clone(), "signal").unwrap();
1956
1957        // Manual calc with same default costs (5bps comm, 2bps slip)
1958        let slip = 0.0002;
1959        let comm = 0.0005;
1960        let init = 100_000.0;
1961        let mut cash = init;
1962        let mut pos = 0.0;
1963        let mut entry = 0.0;
1964        let mut manual_equity = init;
1965
1966        for i in 0..n {
1967            let c = closes[i];
1968            let s = signals[i] > 0.0;
1969
1970            if s && pos == 0.0 {
1971                let fp = c * (1.0 + slip);
1972                cash -= fp * (1.0 + comm);
1973                pos = 1.0;
1974                entry = fp;
1975            } else if !s && pos > 0.0 {
1976                let fp = c * (1.0 - slip);
1977                cash += fp * (1.0 - comm);
1978                let _g = (fp - entry) * pos;
1979                let cost = fp * comm;
1980                cash += -cost; // already subtracted above? adjust
1981                pos = 0.0;
1982            }
1983            manual_equity = cash + pos * c;
1984        }
1985
1986        let engine_final = *result.stats.get("final_equity").unwrap();
1987        // Allow small tolerance due to open position handling and rounding
1988        assert_relative_eq!(engine_final, manual_equity, epsilon = 0.5);
1989    }
1990
1991    // --- quantwave-ug9t: Streaming simulation + batch vs streaming parity verification ---
1992
1993    /// Synthetic PA "pole height" detector (stub for parity test only).
1994    /// Computes rolling range over small window as proxy for "pole height"
1995    /// (swing amplitude used for conviction sizing). Not a production detector.
1996    /// Concept source: MQL5 PA pattern metadata (quantwave-366) + Ehlers turning
1997    /// point anticipation (artifacts/); synthetic impl recorded per AGENTS.md.
1998    #[derive(Debug, Clone)]
1999    struct SyntheticPoleHeightDetector {
2000        window: Vec<f64>,
2001        max_len: usize,
2002    }
2003
2004    impl SyntheticPoleHeightDetector {
2005        fn new(max_len: usize) -> Self {
2006            Self {
2007                window: Vec::with_capacity(max_len),
2008                max_len,
2009            }
2010        }
2011    }
2012
2013    #[derive(Debug, Clone, Copy)]
2014    struct PoleOutput {
2015        pole_height: f64,
2016        _strength: f64, // read via meta in rich parity; prefixed to silence dead_code in this test-only stub
2017    }
2018
2019    impl Next<f64> for SyntheticPoleHeightDetector {
2020        type Output = PoleOutput;
2021
2022        fn next(&mut self, price: f64) -> PoleOutput {
2023            self.window.push(price);
2024            if self.window.len() > self.max_len {
2025                self.window.remove(0);
2026            }
2027            let h = if self.window.len() >= 3 {
2028                let mn = self.window.iter().fold(f64::INFINITY, |a, &b| a.min(b));
2029                let mx = self.window.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
2030                (mx - mn).max(0.1)
2031            } else {
2032                1.0
2033            };
2034            PoleOutput {
2035                pole_height: h,
2036                _strength: (h / 8.0).clamp(0.3, 1.0),
2037            }
2038        }
2039    }
2040
2041    /// Example strategy using regime filter (TAR on price as simplistic signal),
2042    /// feature threshold (CyberCycle momentum), + rich PA pole-height sizing.
2043    /// Demonstrates the "rich metadata + regime + feature" case required by ug9t.
2044    #[derive(Debug, Clone)]
2045    struct RegimeFeaturePAStrategy {
2046        regime: TAR,
2047        cycle: CyberCycleFeatureExtractor,
2048        pa: SyntheticPoleHeightDetector,
2049        feat_thresh: f64,
2050    }
2051
2052    impl RegimeFeaturePAStrategy {
2053        fn new() -> Self {
2054            Self {
2055                regime: TAR::new(105.0), // simplistic threshold on raw price for test synth
2056                cycle: CyberCycleFeatureExtractor::new(14),
2057                pa: SyntheticPoleHeightDetector::new(6),
2058                feat_thresh: 0.02,
2059            }
2060        }
2061    }
2062
2063    impl Next<&Bar> for RegimeFeaturePAStrategy {
2064        type Output = StrategySignal;
2065
2066        fn next(&mut self, bar: &Bar) -> StrategySignal {
2067            let regime = self.regime.next(bar.close);
2068            let feat = self.cycle.next(bar.close);
2069            let pa = self.pa.next(bar.close);
2070
2071            // Regime filter: trade only in Steady/Cluster (synthetic data around 100-110)
2072            let regime_ok = matches!(
2073                regime,
2074                MarketRegime::Steady | MarketRegime::Cluster(_) | MarketRegime::Bull
2075            );
2076            let feat_ok = feat.cycle_momentum.abs() > self.feat_thresh;
2077
2078            let exposure = if regime_ok && feat_ok {
2079                // Pole height sizing: larger detected swing -> larger (clamped) exposure
2080                (pa.pole_height / 4.0).clamp(0.4, 2.2)
2081            } else {
2082                0.0
2083            };
2084
2085            let mut meta = HashMap::new();
2086            meta.insert("pole_height".to_string(), pa.pole_height);
2087            meta.insert("cycle_momentum".to_string(), feat.cycle_momentum);
2088            meta.insert("regime_ok".to_string(), if regime_ok { 1.0 } else { 0.0 });
2089
2090            StrategySignal {
2091                exposure,
2092                metadata: Some(meta),
2093            }
2094        }
2095    }
2096
2097    #[test]
2098    fn test_batch_vs_streaming_parity_regime_feature_rich_pa_pole_sizing() {
2099        // Deterministic synthetic series (no rand) designed to cross regime threshold
2100        // and produce non-trivial feature/pole signals + at least one round-trip trade.
2101        let n: usize = 120;
2102        let mut timestamps = Vec::with_capacity(n);
2103        let mut closes = Vec::with_capacity(n);
2104        let mut price;
2105
2106        for i in 0..n {
2107            let secs = 1_700_000_500i64 + (i as i64) * 3600;
2108            timestamps.push(chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0).unwrap());
2109            // Oscillating + slow drift to cross ~105 threshold and excite cycle
2110            let wave = (i as f64 * 0.18).sin() * 4.5;
2111            price = 101.5 + wave + (i as f64 * 0.008);
2112            closes.push(price);
2113        }
2114
2115        let bars: Vec<Bar> = timestamps
2116            .iter()
2117            .zip(closes.iter())
2118            .map(|(&ts, &close)| Bar {
2119                ts,
2120                close,
2121                high: None,
2122                low: None,
2123            })
2124            .collect();
2125
2126        // --- "Pure vectorized batch" path: precompute exposures via generator pass
2127        // (simulates fast Polars/DF prep of signals from features+PA+regime),
2128        // feed scalar signal col to engine (generalized exposure).
2129        let mut batch_gen = RegimeFeaturePAStrategy::new();
2130        let mut exposures: Vec<f64> = Vec::with_capacity(n);
2131        for bar in &bars {
2132            let s = batch_gen.next(bar);
2133            exposures.push(s.exposure);
2134        }
2135
2136        let df = DataFrame::new(vec![
2137            Column::new(
2138                "timestamp".into(),
2139                timestamps.iter().map(|t| t.timestamp()).collect::<Vec<_>>(),
2140            ),
2141            Column::new("close".into(), closes.clone()),
2142            Column::new("signal".into(), exposures.clone()),
2143        ])
2144        .unwrap();
2145
2146        let batch_res = backtest_simple_bool_signal(df, "signal").expect("batch parity run");
2147
2148        // --- Streaming simulation path (Next<T> generator, live-like)
2149        let stream_gen = RegimeFeaturePAStrategy::new();
2150        let stream_res = run_streaming_simulation(&bars, stream_gen, BacktestConfig::default())
2151            .expect("streaming parity run");
2152
2153        // === PARITY VERIFICATION (make-or-break for ug9t) ===
2154        // 1. Equity curves identical within documented tolerance (1e-8)
2155        let b_eq = batch_res
2156            .equity_curve
2157            .column("equity")
2158            .unwrap()
2159            .f64()
2160            .unwrap()
2161            .into_iter()
2162            .map(|v| v.unwrap_or(0.0))
2163            .collect::<Vec<_>>();
2164        let s_eq = stream_res
2165            .equity_curve
2166            .column("equity")
2167            .unwrap()
2168            .f64()
2169            .unwrap()
2170            .into_iter()
2171            .map(|v| v.unwrap_or(0.0))
2172            .collect::<Vec<_>>();
2173
2174        assert_eq!(b_eq.len(), s_eq.len(), "equity curve lengths must match");
2175        for (i, (b, s)) in b_eq.iter().zip(s_eq.iter()).enumerate() {
2176            approx::assert_relative_eq!(*b, *s, epsilon = 1e-8, max_relative = 1e-8);
2177            // Additional context on failure (approx panics with its own message)
2178            if (b - s).abs() > 1e-7 {
2179                panic!("equity diverged at bar {}: {} vs {}", i, b, s);
2180            }
2181        }
2182
2183        // 2. Core stats match within tolerance
2184        let keys = ["final_equity", "net_pnl", "num_trades"];
2185        for k in keys {
2186            let bv = *batch_res.stats.get(k).unwrap();
2187            let sv = *stream_res.stats.get(k).unwrap();
2188            approx::assert_relative_eq!(bv, sv, epsilon = 1e-6, max_relative = 1e-6);
2189        }
2190
2191        // 3. Trade count exact; pnls within tol (uses rich sizing so non-trivial)
2192        assert_eq!(
2193            batch_res.trades.height(),
2194            stream_res.trades.height(),
2195            "trade counts must match exactly for parity"
2196        );
2197
2198        // Sanity: the strategy using regime+feature+PA must have produced at least 1 trade
2199        // on this data (otherwise test not exercising the rich path).
2200        assert!(
2201            batch_res.trades.height() >= 1,
2202            "parity test strategy must generate >=1 trade on synthetic data"
2203        );
2204
2205        // 4. Rich metadata exercised in streaming path (pole_height present in internal logic)
2206        // (Since detailed trades not exposed in Result, we rely on the generator having
2207        // used pole in exposure calc; equity divergence would have caught bad sizing.)
2208        // For explicit, one could extend API, but this satisfies "uses rich PA struct".
2209    }
2210}
2211
2212// === Small end-to-end integration example between 4ps (ML features) and gwx (backtester) ===
2213// Demonstrates using a feature (Hurst) + simple regime logic to produce StrategySignal
2214// with rich metadata, then feeding it into the backtester.
2215// This is the "smoke test" that the two epics work together.
2216// The full canonical version exercising the complete locked surface (Hurst + CyberCycle struct +
2217// Griffiths DC + regime HMM) + Polars .ta().features() batch + streaming FeatureToSignal adapter
2218// + metadata-in-Trade + exact parity is the living notebook:
2219// docs/examples/notebooks/ml_feature_backtest_parity.py (primary closure artifact for 4ps + gwx).
2220#[cfg(test)]
2221mod integration_example_between_epics {
2222    use super::*;
2223    // use polars::prelude::*;
2224    use quantwave_core::features::HurstFeatureExtractor;
2225
2226    #[test]
2227    fn ml_features_feed_backtester_with_metadata() {
2228        let n = 60;
2229        let closes: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 * 0.25).collect();
2230        // Use i64 unix seconds (supported by extract_timestamps) to avoid df! + DateTime<Utc> macro issues
2231        let timestamps: Vec<i64> = (0..n).map(|i| 1_700_000_000i64 + i as i64).collect();
2232
2233        // Streaming feature computation (exactly as it will come from wlx in the future)
2234        let mut h_ext = HurstFeatureExtractor::new(15);
2235        let mut exposures = Vec::new();
2236
2237        for &c in &closes {
2238            let f = h_ext.next(c);
2239            let regime_ok = true; // would come from regime column in real use
2240            let exposure = if regime_ok && f.persistence > 0.52 {
2241                1.0
2242            } else {
2243                0.0
2244            };
2245            exposures.push(exposure);
2246        }
2247
2248        // Build DF with pre-computed exposure (the pattern the backtester already supports well)
2249        let lf = df![
2250            "timestamp" => timestamps,
2251            "close" => closes,
2252            "exposure" => exposures,
2253        ]
2254        .unwrap()
2255        .lazy();
2256
2257        let config = BacktestConfig {
2258            signal_col: "exposure".to_string(),
2259            ..Default::default()
2260        };
2261
2262        let result = BacktestEngine::new(config).run(lf).unwrap();
2263
2264        // The integration "works" if we can run without panic
2265        println!(
2266            "Integration smoke test: {} trades produced using ML feature (Hurst) driven exposure",
2267            result.trades.height()
2268        );
2269        assert!(result.equity_curve.height() == n);
2270    }
2271
2272    #[test]
2273    fn test_initial_risk_position_sizer_with_pole_height_and_fraction() {
2274        // n1yc.1: verify rich sizer produces risk-budgeted sizes from PA metadata.
2275        let sizer = InitialRiskPositionSizer { initial_risk: 0.01, max_target_pct: 0.5 };
2276        let mut meta = HashMap::new();
2277        meta.insert("pole_height_atr".to_string(), 2.0); // e.g. 2 ATR pole -> frac ~0.005
2278        let sig = StrategySignal { exposure: 1.0, metadata: Some(meta) };
2279        let sized = sizer.compute_sized_exposure(1.0, &sig.metadata, 100.0, 1_000_000.0);
2280        // target_pct ~ 0.01 / (0.01/2) = 2.0 but capped at 0.5 -> 0.5 * equity / price = 5000 units? Wait calc:
2281        // frac = 0.01 / 2.0 = 0.005; target_pct = 0.01 / 0.005 = 2.0 -> min(0.5) = 0.5; target_units = 0.5 * 1e6 / 100 = 5000
2282        assert!((sized - 5000.0).abs() < 1.0);
2283
2284        // explicit fraction_at_risk
2285        let mut meta2 = HashMap::new();
2286        meta2.insert("fraction_at_risk".to_string(), 0.02);
2287        let sig2 = StrategySignal { exposure: 1.0, metadata: Some(meta2) };
2288        let sized2 = sizer.compute_sized_exposure(1.0, &sig2.metadata, 100.0, 1_000_000.0);
2289        // 0.01 / 0.02 = 0.5; 0.5 * 1e6 /100 = 5000
2290        assert!((sized2 - 5000.0).abs() < 1.0);
2291
2292        // no meta -> passthrough
2293        let sig3 = StrategySignal { exposure: 123.0, metadata: None };
2294        let sized3 = sizer.compute_sized_exposure(123.0, &sig3.metadata, 100.0, 1_000_000.0);
2295        assert!((sized3 - 123.0).abs() < 1e-9);
2296    }
2297}