1mod 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};
87pub use cross_sectional::{
88 CrossSectionalConfig, assign_long_short_exposure, neutralize_factor,
89 run_cross_sectional_backtest, winsorize_factor, zscore_factor,
90};
91pub use live_bridge::{LiveBridge, LiveBridgeError, LiveSignalEvent, RecordingLiveBridge};
92pub use metrics::{BacktestReport, PerformanceMetrics};
93pub use monte_carlo::{
94 MonteCarloConfig, MonteCarloPathSummary, MonteCarloReturnConfig, MonteCarloSummary,
95 monte_carlo_return_paths, monte_carlo_trade_bootstrap,
96};
97use polars::prelude::*;
98pub use portfolio::{
99 PortfolioAllocator, PortfolioBar, PortfolioMode, run_shared_capital_streaming_simulation,
100};
101#[allow(unused_imports)]
102use quantwave_core::traits::Next; use serde::{Deserialize, Serialize};
104use std::collections::HashMap;
105pub use stops::{StopConfig, StopEvaluationMode};
106pub use sweep::{SweepVariant, run_param_sweep, single_param_variants};
107pub use tearsheet::{TearsheetOptions, render_tearsheet_html};
108use thiserror::Error;
109pub use walk_forward::{WalkForwardConfig, run_walk_forward, run_walk_forward_optimize};
110
111#[derive(Error, Debug)]
113pub enum BacktestError {
114 #[error("Polars error during simulation: {0}")]
115 Polars(#[from] PolarsError),
116
117 #[error("Invalid input: {0}")]
118 InvalidInput(String),
119
120 #[error("missing column: {name}")]
121 MissingColumn { name: String },
122
123 #[error("invalid dtype for column '{col}': expected {expected}, got {got}")]
124 InvalidDtype {
125 col: String,
126 expected: String,
127 got: String,
128 },
129
130 #[error("internal invariant violated: {context}")]
131 InternalInvariant { context: String },
132
133 #[error("Data must be sorted by timestamp (and symbol for multi-symbol runs)")]
134 UnsortedData,
135}
136
137fn require_symbol_col(symbol_col: &Option<String>) -> Result<&str, BacktestError> {
138 symbol_col.as_deref().ok_or_else(|| {
139 BacktestError::InvalidInput("symbol_col required for multi-symbol backtest".into())
140 })
141}
142
143pub(crate) fn internal_invariant(context: impl Into<String>) -> BacktestError {
144 BacktestError::InternalInvariant {
145 context: context.into(),
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct CostModel {
152 pub commission_bps: f64,
154 pub slippage_bps: f64,
156 pub initial_cash: f64,
158}
159
160impl Default for CostModel {
161 fn default() -> Self {
162 Self {
163 commission_bps: 5.0, slippage_bps: 2.0, initial_cash: 100_000.0,
166 }
167 }
168}
169
170pub trait CommissionModel: Send + Sync + std::fmt::Debug {
172 fn calculate_commission(&self, fill_quantity: f64, fill_price: f64) -> f64;
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, Default)]
176pub struct BpsCommissionModel {
177 pub bps: f64,
179}
180
181impl CommissionModel for BpsCommissionModel {
182 fn calculate_commission(&self, fill_quantity: f64, fill_price: f64) -> f64 {
183 (fill_quantity.abs() * fill_price) * (self.bps / 10_000.0)
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188pub struct FixedPerShareCommissionModel {
189 pub per_share: f64,
190}
191
192impl CommissionModel for FixedPerShareCommissionModel {
193 fn calculate_commission(&self, fill_quantity: f64, _fill_price: f64) -> f64 {
194 fill_quantity.abs() * self.per_share
195 }
196}
197
198pub trait SlippageModel: Send + Sync + std::fmt::Debug {
200 fn apply(&self, price: f64, quantity: f64, is_buy: bool, adv: Option<f64>) -> f64;
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct BpsSlippageModel {
205 pub bps: f64,
206}
207
208impl SlippageModel for BpsSlippageModel {
209 fn apply(&self, price: f64, _quantity: f64, is_buy: bool, _adv: Option<f64>) -> f64 {
210 let s = self.bps / 10_000.0;
211 if is_buy {
212 price * (1.0 + s)
213 } else {
214 price * (1.0 - s)
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, Default)]
220pub struct SquareRootMarketImpactSlippage {
221 pub impact_coef: f64,
222 pub max_participation: f64,
223}
224
225impl SlippageModel for SquareRootMarketImpactSlippage {
226 fn apply(&self, price: f64, quantity: f64, is_buy: bool, adv: Option<f64>) -> f64 {
227 let adv = adv.unwrap_or(1_000_000.0);
228 let part = (quantity.abs() / adv).min(self.max_participation);
229 let impact = self.impact_coef * part.sqrt();
230 if is_buy {
231 price * (1.0 + impact)
232 } else {
233 price * (1.0 - impact)
234 }
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
240pub enum ExecutionDelay {
241 #[default]
243 SameBar,
244 NextBar,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub enum ExecutionModel {
251 Simple(CostModel),
252 HighFidelity {
253 commission: BpsCommissionModel,
254 slippage: SquareRootMarketImpactSlippage,
255 },
256}
257
258impl Default for ExecutionModel {
259 fn default() -> Self {
260 ExecutionModel::Simple(CostModel::default())
261 }
262}
263
264impl ExecutionModel {
265 pub fn commission_for(&self, qty: f64, px: f64) -> f64 {
266 match self {
267 ExecutionModel::Simple(cm) => (qty.abs() * px) * (cm.commission_bps / 10_000.0),
268 ExecutionModel::HighFidelity { commission, .. } => {
269 commission.calculate_commission(qty, px)
270 }
271 }
272 }
273 pub fn slippage_price(&self, price: f64, qty: f64, is_buy: bool, adv: Option<f64>) -> f64 {
274 match self {
275 ExecutionModel::Simple(cm) => {
276 let s = cm.slippage_bps / 10_000.0;
277 if is_buy {
278 price * (1.0 + s)
279 } else {
280 price * (1.0 - s)
281 }
282 }
283 ExecutionModel::HighFidelity { slippage, .. } => {
284 slippage.apply(price, qty, is_buy, adv)
285 }
286 }
287 }
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct InitialRiskPositionSizer {
296 pub initial_risk: f64,
298 pub max_target_pct: f64,
300}
301
302impl Default for InitialRiskPositionSizer {
303 fn default() -> Self {
304 Self {
305 initial_risk: 0.01,
306 max_target_pct: 0.25,
307 }
308 }
309}
310
311impl InitialRiskPositionSizer {
312 pub fn compute_sized_exposure(
316 &self,
317 raw_exposure: f64,
318 meta: &Option<HashMap<String, f64>>,
319 price: f64,
320 equity: f64,
321 ) -> f64 {
322 let sign = if raw_exposure > 0.0 {
323 1.0
324 } else if raw_exposure < 0.0 {
325 -1.0
326 } else {
327 0.0
328 };
329 if let Some(m) = meta {
330 if let Some(frac) = m.get("fraction_at_risk").copied()
332 && frac > 0.0
333 {
334 let target_pct = (self.initial_risk / frac).min(self.max_target_pct);
335 let target_units = target_pct * equity / price * sign;
336 return target_units;
337 }
338 if let Some(pole) = m.get("pole_height_atr").copied()
340 && pole > 0.0
341 {
342 let frac = 0.01 / pole;
344 let target_pct = (self.initial_risk / frac).min(self.max_target_pct);
345 let target_units = target_pct * equity / price * sign;
346 return target_units;
347 }
348 }
349 raw_exposure
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct BacktestConfig {
356 pub cost_model: CostModel,
357 pub timestamp_col: String,
359 pub symbol_col: Option<String>,
360 pub close_col: String,
361 pub high_col: Option<String>,
363 pub low_col: Option<String>,
365 pub signal_col: String,
372 pub entry_filter_col: Option<String>,
376 pub size_multiplier_col: Option<String>,
379
380 pub execution_model: ExecutionModel,
382 pub execution_delay: ExecutionDelay,
384 pub stop_config: StopConfig,
386 pub position_sizer: Option<InitialRiskPositionSizer>,
389 pub portfolio_mode: PortfolioMode,
391 pub portfolio_allocator: PortfolioAllocator,
393}
394
395impl Default for BacktestConfig {
396 fn default() -> Self {
397 Self {
398 cost_model: CostModel::default(),
399 timestamp_col: "timestamp".to_string(),
400 symbol_col: None,
401 close_col: "close".to_string(),
402 high_col: None,
403 low_col: None,
404 signal_col: "signal".to_string(),
405 entry_filter_col: None,
406 size_multiplier_col: None,
407 execution_model: ExecutionModel::default(),
408 execution_delay: ExecutionDelay::default(),
409 stop_config: StopConfig::default(),
410 position_sizer: None,
411 portfolio_mode: PortfolioMode::default(),
412 portfolio_allocator: PortfolioAllocator::default(),
413 }
414 }
415}
416
417fn signal_bar_index(bar: usize, delay: ExecutionDelay) -> Option<usize> {
419 match delay {
420 ExecutionDelay::SameBar => Some(bar),
421 ExecutionDelay::NextBar => bar.checked_sub(1),
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct Trade {
428 pub trade_id: u32,
429 pub symbol: Option<String>,
430 pub side: i8, pub entry_ts: DateTime<Utc>,
432 pub entry_price: f64,
433 pub entry_fill_price: f64, pub exit_ts: Option<DateTime<Utc>>,
435 pub exit_price: Option<f64>,
436 pub exit_fill_price: Option<f64>,
437 pub pnl_gross: f64,
438 pub costs: f64,
439 pub pnl_net: f64,
440 pub quantity: f64,
443 pub entry_metadata: Option<HashMap<String, f64>>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct EquityPoint {
451 pub ts: DateTime<Utc>,
452 pub symbol: Option<String>, pub equity: f64,
454 pub cash: f64,
455 pub position: f64, pub close: f64,
457}
458
459#[derive(Debug)]
461pub struct BacktestResult {
462 pub trades: DataFrame,
464 pub equity_curve: DataFrame,
466 pub stats: HashMap<String, f64>,
468}
469
470impl BacktestResult {
471 pub fn metrics(&self) -> PerformanceMetrics {
473 PerformanceMetrics::from_result(self)
474 }
475}
476
477#[derive(Debug, Clone)]
479pub struct Bar {
480 pub ts: DateTime<Utc>,
481 pub close: f64,
482 pub high: Option<f64>,
484 pub low: Option<f64>,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct StrategySignal {
493 pub exposure: f64,
495 pub metadata: Option<HashMap<String, f64>>,
498}
499
500impl Default for StrategySignal {
501 fn default() -> Self {
502 Self {
503 exposure: 0.0,
504 metadata: None,
505 }
506 }
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
513pub struct PAEvent {
514 pub long: bool,
516 pub pole_height: Option<f64>,
518 pub strength: Option<f64>,
520}
521
522impl PAEvent {
523 pub fn to_strategy_signal(&self) -> StrategySignal {
525 let mut meta = HashMap::new();
526 if let Some(p) = self.pole_height {
527 meta.insert("pole_height".to_string(), p);
528 }
529 if let Some(s) = self.strength {
530 meta.insert("strength".to_string(), s);
531 }
532 let exposure = if self.long {
533 self.pole_height.map(pole_height_to_exposure).unwrap_or(1.0)
534 } else {
535 0.0
536 };
537 StrategySignal {
538 exposure,
539 metadata: if meta.is_empty() { None } else { Some(meta) },
540 }
541 }
542}
543
544pub fn pole_height_to_exposure(pole_height: f64) -> f64 {
546 (pole_height / 4.0).clamp(0.4, 2.2)
547}
548
549pub fn parse_struct_signal_row(
557 ca: &StructChunked,
558 i: usize,
559) -> Result<(f64, Option<HashMap<String, f64>>), BacktestError> {
560 let mut meta = HashMap::new();
561
562 let exposure_direct = struct_field_f64(ca, "exposure", i);
563 let long = struct_field_bool(ca, "long", i);
564 let short = struct_field_bool(ca, "short", i);
565
566 if let DataType::Struct(fields) = ca.dtype() {
567 for field in fields {
568 let key = field.name.as_str();
569 if matches!(key, "exposure" | "long" | "short") {
570 continue;
571 }
572 if let Some(v) = struct_field_f64(ca, key, i)
573 && v.is_finite()
574 {
575 meta.insert(key.to_string(), v);
576 }
577 }
578 }
579
580 let pole = ["pole_height", "pole_height_atr", "pole_length_atr"]
581 .iter()
582 .find_map(|name| meta.get(*name).copied())
583 .filter(|v| *v > 0.0);
584
585 let exposure = if let Some(e) = exposure_direct {
586 if e.is_finite() && e != 0.0 {
587 e
588 } else if short.unwrap_or(false) {
589 let mag = pole.map(pole_height_to_exposure).unwrap_or(1.0);
590 -mag
591 } else if long.unwrap_or(false) {
592 pole.map(pole_height_to_exposure).unwrap_or(1.0)
593 } else {
594 0.0
595 }
596 } else if short.unwrap_or(false) {
597 let mag = pole.map(pole_height_to_exposure).unwrap_or(1.0);
598 -mag
599 } else if long.unwrap_or(false) {
600 pole.map(pole_height_to_exposure).unwrap_or(1.0)
601 } else {
602 0.0
603 };
604
605 let metadata = if meta.is_empty() { None } else { Some(meta) };
606 Ok((exposure, metadata))
607}
608
609fn struct_field_f64(ca: &StructChunked, name: &str, i: usize) -> Option<f64> {
610 let field = ca.field_by_name(name).ok()?;
611 field.f64().ok().and_then(|arr| arr.get(i))
612}
613
614fn struct_field_bool(ca: &StructChunked, name: &str, i: usize) -> Option<bool> {
615 let field = ca.field_by_name(name).ok()?;
616 field.bool().ok().and_then(|arr| arr.get(i))
617}
618
619pub struct BacktestEngine {
630 config: BacktestConfig,
631}
632
633impl BacktestEngine {
634 pub fn new(config: BacktestConfig) -> Self {
635 Self { config }
636 }
637
638 pub fn with_default_costs() -> Self {
639 Self::new(BacktestConfig::default())
640 }
641
642 pub fn backtest_with_report(&self, lf: LazyFrame) -> Result<BacktestReport, BacktestError> {
644 let result = self.run(lf)?;
645 let metrics = PerformanceMetrics::from_result(&result);
646 Ok(BacktestReport { result, metrics })
647 }
648
649 pub fn run(&self, lf: LazyFrame) -> Result<BacktestResult, BacktestError> {
653 let df = lf.collect()?;
654
655 if df.height() == 0 {
656 return Err(BacktestError::InvalidInput("empty dataframe".into()));
657 }
658
659 let ts_col = &self.config.timestamp_col;
660 let close_col = &self.config.close_col;
661 let sig_col = &self.config.signal_col;
662
663 for c in [ts_col, close_col, sig_col] {
664 if df.column(c).is_err() {
665 return Err(BacktestError::MissingColumn {
666 name: (*c).to_string(),
667 });
668 }
669 }
670
671 if self.config.symbol_col.is_some() {
672 return match self.config.portfolio_mode {
673 PortfolioMode::SharedCapital => self.run_shared_capital_multi_symbol(df),
674 PortfolioMode::IndependentBooks => self.run_multi_symbol(df),
675 };
676 }
677
678 self.run_single_symbol(df)
679 }
680
681 pub fn run_metrics_only(&self, lf: LazyFrame) -> Result<PerformanceMetrics, BacktestError> {
682 let df = lf.collect()?;
683
684 if df.height() == 0 {
685 return Err(BacktestError::InvalidInput("empty dataframe".into()));
686 }
687
688 let ts_col = &self.config.timestamp_col;
689 let close_col = &self.config.close_col;
690 let sig_col = &self.config.signal_col;
691
692 for c in [ts_col, close_col, sig_col] {
693 if df.column(c).is_err() {
694 return Err(BacktestError::MissingColumn {
695 name: (*c).to_string(),
696 });
697 }
698 }
699
700 if self.config.symbol_col.is_some() {
701 return match self.config.portfolio_mode {
702 PortfolioMode::SharedCapital => self.run_metrics_shared_capital(df),
703 PortfolioMode::IndependentBooks => self.run_metrics_multi_symbol(df),
704 };
705 }
706
707 self.run_metrics_single_symbol(df)
708 }
709
710 fn run_metrics_single_symbol(
711 &self,
712 df: DataFrame,
713 ) -> Result<PerformanceMetrics, BacktestError> {
714 let (trades, equity_points) = self.simulate_dataframe(&df, None)?;
715 Ok(PerformanceMetrics::from_raw(
716 &trades,
717 &equity_points,
718 self.per_symbol_initial_cash(),
719 ))
720 }
721
722 fn run_metrics_multi_symbol(&self, df: DataFrame) -> Result<PerformanceMetrics, BacktestError> {
723 let sym_col = require_symbol_col(&self.config.symbol_col)?;
724
725 if df.column(sym_col).is_err() {
726 return Err(BacktestError::MissingColumn {
727 name: sym_col.to_string(),
728 });
729 }
730
731 let ts_series = df.column(&self.config.timestamp_col)?.clone();
732 let timestamps = self.extract_timestamps(&ts_series, &self.config.timestamp_col)?;
733 let symbols = extract_string_column(df.column(sym_col)?.clone(), sym_col)?;
734 validate_sorted_timestamp_symbol(×tamps, &symbols)?;
735
736 let mut unique_symbols: Vec<String> = Vec::new();
737 let mut seen = std::collections::HashSet::new();
738 for s in &symbols {
739 if seen.insert(s.clone()) {
740 unique_symbols.push(s.clone());
741 }
742 }
743
744 let mut all_trades: Vec<Trade> = Vec::new();
745 let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
746
747 for symbol in &unique_symbols {
748 let sub = df
749 .clone()
750 .lazy()
751 .filter(col(sym_col).eq(lit(symbol.as_str())))
752 .sort([&self.config.timestamp_col], SortMultipleOptions::default())
753 .collect()?;
754
755 let (mut trades, equity_points) = self.simulate_dataframe(&sub, Some(symbol))?;
756 all_trades.append(&mut trades);
757 per_symbol_equity.insert(symbol.clone(), equity_points);
758 }
759
760 let portfolio_equity = aggregate_portfolio_equity(&per_symbol_equity);
761 let n_symbols = unique_symbols.len() as f64;
762 let portfolio_initial = self.per_symbol_initial_cash() * n_symbols;
763 Ok(PerformanceMetrics::from_raw(
764 &all_trades,
765 &portfolio_equity,
766 portfolio_initial,
767 ))
768 }
769
770 fn run_single_symbol(&self, df: DataFrame) -> Result<BacktestResult, BacktestError> {
771 let (trades, equity_points) = self.simulate_dataframe(&df, None)?;
772
773 let initial_cash = self.per_symbol_initial_cash();
774 let final_equity = equity_points
775 .last()
776 .map(|e| e.equity)
777 .unwrap_or(initial_cash);
778 let total_return = (final_equity - initial_cash) / initial_cash;
779 let num_trades = trades.len() as f64;
780
781 let mut stats = HashMap::new();
782 stats.insert("initial_cash".to_string(), initial_cash);
783 stats.insert("final_equity".to_string(), final_equity);
784 stats.insert("total_return".to_string(), total_return);
785 stats.insert("num_trades".to_string(), num_trades);
786 stats.insert("net_pnl".to_string(), final_equity - initial_cash);
787
788 Ok(BacktestResult {
789 trades: self.trades_to_df(&trades, false)?,
790 equity_curve: self.equity_to_df(&equity_points, false)?,
791 stats,
792 })
793 }
794
795 fn run_multi_symbol(&self, df: DataFrame) -> Result<BacktestResult, BacktestError> {
796 let sym_col = require_symbol_col(&self.config.symbol_col)?;
797
798 if df.column(sym_col).is_err() {
799 return Err(BacktestError::MissingColumn {
800 name: sym_col.to_string(),
801 });
802 }
803
804 let ts_series = df.column(&self.config.timestamp_col)?.clone();
805 let timestamps = self.extract_timestamps(&ts_series, &self.config.timestamp_col)?;
806 let symbols = extract_string_column(df.column(sym_col)?.clone(), sym_col)?;
807 validate_sorted_timestamp_symbol(×tamps, &symbols)?;
808
809 let mut unique_symbols: Vec<String> = Vec::new();
810 let mut seen = std::collections::HashSet::new();
811 for s in &symbols {
812 if seen.insert(s.clone()) {
813 unique_symbols.push(s.clone());
814 }
815 }
816
817 let per_symbol_initial = self.per_symbol_initial_cash();
818 let mut all_trades: Vec<Trade> = Vec::new();
819 let mut per_symbol_equity: HashMap<String, Vec<EquityPoint>> = HashMap::new();
820
821 for symbol in &unique_symbols {
822 let sub = df
823 .clone()
824 .lazy()
825 .filter(col(sym_col).eq(lit(symbol.as_str())))
826 .sort([&self.config.timestamp_col], SortMultipleOptions::default())
827 .collect()?;
828
829 let (mut trades, equity_points) = self.simulate_dataframe(&sub, Some(symbol))?;
830 all_trades.append(&mut trades);
831 per_symbol_equity.insert(symbol.clone(), equity_points);
832 }
833
834 let portfolio_equity = aggregate_portfolio_equity(&per_symbol_equity);
835 let mut combined_equity: Vec<EquityPoint> =
836 per_symbol_equity.values().flatten().cloned().collect();
837 combined_equity.extend(portfolio_equity.clone());
838
839 let n_symbols = unique_symbols.len() as f64;
840 let portfolio_initial = per_symbol_initial * n_symbols;
841 let portfolio_final = portfolio_equity
842 .last()
843 .map(|e| e.equity)
844 .unwrap_or(portfolio_initial);
845 let total_return = (portfolio_final - portfolio_initial) / portfolio_initial;
846 let num_trades = all_trades.len() as f64;
847
848 let mut stats = HashMap::new();
849 stats.insert("initial_cash".to_string(), portfolio_initial);
850 stats.insert("final_equity".to_string(), portfolio_final);
851 stats.insert("total_return".to_string(), total_return);
852 stats.insert("num_trades".to_string(), num_trades);
853 stats.insert("net_pnl".to_string(), portfolio_final - portfolio_initial);
854 stats.insert("num_symbols".to_string(), n_symbols);
855
856 Ok(BacktestResult {
857 trades: self.trades_to_df(&all_trades, true)?,
858 equity_curve: self.equity_to_df(&combined_equity, true)?,
859 stats,
860 })
861 }
862
863 fn run_shared_capital_multi_symbol(
864 &self,
865 df: DataFrame,
866 ) -> Result<BacktestResult, BacktestError> {
867 let sym_col = require_symbol_col(&self.config.symbol_col)?;
868
869 let ts_series = df.column(&self.config.timestamp_col)?.clone();
870 let timestamps = self.extract_timestamps(&ts_series, &self.config.timestamp_col)?;
871 let symbols = extract_string_column(df.column(sym_col)?.clone(), sym_col)?;
872 validate_sorted_timestamp_symbol(×tamps, &symbols)?;
873
874 let close_ca = df.column(&self.config.close_col)?.f64()?.clone();
875 let closes: Vec<f64> = close_ca.into_iter().map(|v| v.unwrap_or(0.0)).collect();
876 let (highs, lows) = self.load_ohlc_columns(&df)?;
877 let (signal_vals, signal_metas) = self.load_signals(&df, &self.config.signal_col)?;
878 let entry_filters = self.load_entry_filters(&df)?;
879 let size_multipliers = self.load_size_multipliers(&df)?;
880
881 let mut adjusted_signals: Vec<f64> = Vec::with_capacity(signal_vals.len());
882 for i in 0..signal_vals.len() {
883 let filter = entry_filters.as_ref().and_then(|f| f.get(i).copied());
884 let mult = size_multipliers.as_ref().and_then(|m| m.get(i).copied());
885 adjusted_signals.push(apply_signal_modifiers(signal_vals[i], filter, mult));
886 }
887
888 use std::collections::BTreeMap;
890 let mut by_ts: BTreeMap<DateTime<Utc>, Vec<usize>> = BTreeMap::new();
891 for (i, t) in timestamps.iter().enumerate() {
892 by_ts.entry(*t).or_default().push(i);
893 }
894 let unique_ts: Vec<DateTime<Utc>> = by_ts.keys().copied().collect();
895 let mut delayed_signals = vec![0.0; adjusted_signals.len()];
896 let mut delayed_metas: Vec<Option<HashMap<String, f64>>> = vec![None; signal_metas.len()];
897 for (gi, ts) in unique_ts.iter().enumerate() {
898 let source_by_sym: HashMap<String, (f64, Option<HashMap<String, f64>>)> =
899 if let Some(si) = signal_bar_index(gi, self.config.execution_delay) {
900 by_ts
901 .get(&unique_ts[si])
902 .into_iter()
903 .flatten()
904 .map(|&idx| {
905 (
906 symbols[idx].clone(),
907 (adjusted_signals[idx], signal_metas[idx].clone()),
908 )
909 })
910 .collect()
911 } else {
912 HashMap::new()
913 };
914 for &idx in by_ts.get(ts).unwrap_or(&vec![]) {
915 if let Some((s, m)) = source_by_sym.get(&symbols[idx]) {
916 delayed_signals[idx] = *s;
917 delayed_metas[idx] = m.clone();
918 }
919 }
920 }
921
922 let groups = portfolio::build_timestamp_groups(
923 &delayed_signals,
924 &delayed_metas,
925 &symbols,
926 ×tamps,
927 &closes,
928 highs.as_deref(),
929 lows.as_deref(),
930 );
931
932 let (trades, per_symbol_equity, portfolio_eq) = portfolio::simulate_shared_capital(
933 &groups,
934 &self.config.execution_model,
935 &self.config.position_sizer,
936 self.config.execution_delay,
937 &self.config.stop_config,
938 self.config.portfolio_allocator,
939 );
940
941 Self::assemble_shared_capital_result(&self.config, trades, per_symbol_equity, portfolio_eq)
942 }
943
944 fn run_metrics_shared_capital(
945 &self,
946 df: DataFrame,
947 ) -> Result<PerformanceMetrics, BacktestError> {
948 let result = self.run_shared_capital_multi_symbol(df)?;
949 Ok(PerformanceMetrics::from_result(&result))
950 }
951
952 pub(crate) fn assemble_shared_capital_result(
954 config: &BacktestConfig,
955 trades: Vec<Trade>,
956 per_symbol_equity: HashMap<String, Vec<EquityPoint>>,
957 portfolio_equity: Vec<EquityPoint>,
958 ) -> Result<BacktestResult, BacktestError> {
959 let engine = BacktestEngine::new(config.clone());
960 let mut combined_equity: Vec<EquityPoint> =
961 per_symbol_equity.values().flatten().cloned().collect();
962 combined_equity.extend(portfolio_equity.clone());
963
964 let initial_cash = match &config.execution_model {
965 ExecutionModel::Simple(cm) => cm.initial_cash,
966 _ => 100_000.0,
967 };
968 let portfolio_final = portfolio_equity
969 .last()
970 .map(|e| e.equity)
971 .unwrap_or(initial_cash);
972 let total_return = (portfolio_final - initial_cash) / initial_cash;
973 let num_trades = trades.len() as f64;
974 let n_symbols = per_symbol_equity.len() as f64;
975
976 let mut stats = HashMap::new();
977 stats.insert("initial_cash".to_string(), initial_cash);
978 stats.insert("final_equity".to_string(), portfolio_final);
979 stats.insert("total_return".to_string(), total_return);
980 stats.insert("num_trades".to_string(), num_trades);
981 stats.insert("net_pnl".to_string(), portfolio_final - initial_cash);
982 stats.insert("num_symbols".to_string(), n_symbols);
983 stats.insert("portfolio_mode".to_string(), 1.0); Ok(BacktestResult {
986 trades: engine.trades_to_df(&trades, true)?,
987 equity_curve: engine.equity_to_df(&combined_equity, true)?,
988 stats,
989 })
990 }
991
992 fn per_symbol_initial_cash(&self) -> f64 {
993 match &self.config.execution_model {
994 ExecutionModel::Simple(cm) => cm.initial_cash,
995 _ => 100_000.0,
996 }
997 }
998
999 fn simulate_dataframe(
1000 &self,
1001 df: &DataFrame,
1002 symbol: Option<&str>,
1003 ) -> Result<(Vec<Trade>, Vec<EquityPoint>), BacktestError> {
1004 let ts_col = &self.config.timestamp_col;
1005 let close_col = &self.config.close_col;
1006 let sig_col = &self.config.signal_col;
1007
1008 let ts_series = df.column(ts_col)?.clone();
1009 let timestamps = self.extract_timestamps(&ts_series, ts_col)?;
1010 let close_ca = df.column(close_col)?.f64()?.clone();
1011 let (signal_vals, signal_metas) = self.load_signals(df, sig_col)?;
1012
1013 let entry_filters = self.load_entry_filters(df)?;
1014 let size_multipliers = self.load_size_multipliers(df)?;
1015
1016 let n = signal_vals.len();
1017 if let Some(ref f) = entry_filters
1018 && f.len() != n
1019 {
1020 return Err(BacktestError::InvalidInput(
1021 "entry_filter column length mismatch".into(),
1022 ));
1023 }
1024 if let Some(ref m) = size_multipliers
1025 && m.len() != n
1026 {
1027 return Err(BacktestError::InvalidInput(
1028 "size_multiplier column length mismatch".into(),
1029 ));
1030 }
1031
1032 let effective_signals: Vec<f64> = signal_vals
1033 .iter()
1034 .enumerate()
1035 .map(|(i, &raw)| {
1036 apply_signal_modifiers(
1037 raw,
1038 entry_filters.as_ref().map(|f| f[i]),
1039 size_multipliers.as_ref().map(|m| m[i]),
1040 )
1041 })
1042 .collect();
1043
1044 let closes: Vec<f64> = close_ca
1045 .into_iter()
1046 .map(|v| v.unwrap_or(f64::NAN))
1047 .collect();
1048
1049 if timestamps.len() != closes.len() || closes.len() != effective_signals.len() {
1050 return Err(BacktestError::InvalidInput("column length mismatch".into()));
1051 }
1052
1053 let exec = &self.config.execution_model;
1054 let sizer = &self.config.position_sizer;
1055 let mut effective_metas: Vec<Option<HashMap<String, f64>>> =
1056 Vec::with_capacity(effective_signals.len());
1057 for (i, &raw) in effective_signals.iter().enumerate() {
1058 if raw == 0.0 {
1059 effective_metas.push(None);
1060 } else {
1061 effective_metas.push(signal_metas.get(i).cloned().flatten());
1062 }
1063 }
1064 let (highs, lows) = self.load_ohlc_columns(df)?;
1065 let delay = self.config.execution_delay;
1066 let stops = &self.config.stop_config;
1067 let (mut trades, mut equity_points) = run_simulation(
1068 ×tamps,
1069 &closes,
1070 highs.as_deref(),
1071 lows.as_deref(),
1072 |i| (effective_signals[i], effective_metas[i].clone()),
1073 exec,
1074 sizer,
1075 delay,
1076 stops,
1077 );
1078
1079 if let Some(sym) = symbol {
1080 let sym_owned = sym.to_string();
1081 for t in &mut trades {
1082 t.symbol = Some(sym_owned.clone());
1083 }
1084 for e in &mut equity_points {
1085 e.symbol = Some(sym_owned.clone());
1086 }
1087 }
1088
1089 Ok((trades, equity_points))
1090 }
1091
1092 fn load_signals(
1093 &self,
1094 df: &DataFrame,
1095 sig_col: &str,
1096 ) -> Result<(Vec<f64>, Vec<Option<HashMap<String, f64>>>), BacktestError> {
1097 let signal_series = df.column(sig_col)?;
1098
1099 if signal_series.dtype().is_struct() {
1100 let s = signal_series
1101 .as_series()
1102 .ok_or_else(|| BacktestError::InvalidDtype {
1103 col: sig_col.to_string(),
1104 expected: "Struct signal column".into(),
1105 got: format!("{:?}", signal_series.dtype()),
1106 })?;
1107 let ca = s.struct_().map_err(BacktestError::Polars)?;
1108 let n = ca.len();
1109 let mut exposures = Vec::with_capacity(n);
1110 let mut metas = Vec::with_capacity(n);
1111 for i in 0..n {
1112 let (exp, meta) = parse_struct_signal_row(ca, i)?;
1113 exposures.push(exp);
1114 metas.push(meta);
1115 }
1116 return Ok((exposures, metas));
1117 }
1118
1119 let signal_vals: Vec<f64> = if signal_series.dtype().is_bool() {
1120 signal_series
1121 .bool()
1122 .map_err(|_| BacktestError::InvalidDtype {
1123 col: sig_col.to_string(),
1124 expected: "Boolean or Float64".into(),
1125 got: format!("{:?}", signal_series.dtype()),
1126 })?
1127 .into_iter()
1128 .map(|b| if b.unwrap_or(false) { 1.0 } else { 0.0 })
1129 .collect()
1130 } else if signal_series.dtype().is_float() {
1131 signal_series
1132 .f64()
1133 .map_err(|_| BacktestError::InvalidDtype {
1134 col: sig_col.to_string(),
1135 expected: "Boolean or Float64".into(),
1136 got: format!("{:?}", signal_series.dtype()),
1137 })?
1138 .into_iter()
1139 .map(|v| v.unwrap_or(0.0))
1140 .collect()
1141 } else {
1142 return Err(BacktestError::InvalidDtype {
1143 col: sig_col.to_string(),
1144 expected: "Boolean or Float64".into(),
1145 got: format!("{:?}", signal_series.dtype()),
1146 });
1147 };
1148 let metas = vec![None; signal_vals.len()];
1149 Ok((signal_vals, metas))
1150 }
1151
1152 fn load_entry_filters(&self, df: &DataFrame) -> Result<Option<Vec<bool>>, BacktestError> {
1153 let Some(col_name) = &self.config.entry_filter_col else {
1154 return Ok(None);
1155 };
1156 if df.column(col_name).is_err() {
1157 return Err(BacktestError::MissingColumn {
1158 name: col_name.clone(),
1159 });
1160 }
1161 extract_bool_column(df.column(col_name)?.clone(), col_name).map(Some)
1162 }
1163
1164 fn load_size_multipliers(&self, df: &DataFrame) -> Result<Option<Vec<f64>>, BacktestError> {
1165 let Some(col_name) = &self.config.size_multiplier_col else {
1166 return Ok(None);
1167 };
1168 if df.column(col_name).is_err() {
1169 return Err(BacktestError::MissingColumn {
1170 name: col_name.clone(),
1171 });
1172 }
1173 extract_f64_column(df.column(col_name)?.clone(), col_name).map(Some)
1174 }
1175
1176 fn load_ohlc_columns(
1178 &self,
1179 df: &DataFrame,
1180 ) -> Result<(Option<Vec<f64>>, Option<Vec<f64>>), BacktestError> {
1181 if self.config.stop_config.stop_evaluation != StopEvaluationMode::OhlcTouched {
1182 return Ok((None, None));
1183 }
1184 let high_col = self.config.high_col.as_ref().ok_or_else(|| {
1185 BacktestError::InvalidInput("OhlcTouched stop evaluation requires high_col".into())
1186 })?;
1187 let low_col = self.config.low_col.as_ref().ok_or_else(|| {
1188 BacktestError::InvalidInput("OhlcTouched stop evaluation requires low_col".into())
1189 })?;
1190 if df.column(high_col).is_err() {
1191 return Err(BacktestError::MissingColumn {
1192 name: high_col.clone(),
1193 });
1194 }
1195 if df.column(low_col).is_err() {
1196 return Err(BacktestError::MissingColumn {
1197 name: low_col.clone(),
1198 });
1199 }
1200 let highs = extract_f64_column(df.column(high_col)?.clone(), high_col)?;
1201 let lows = extract_f64_column(df.column(low_col)?.clone(), low_col)?;
1202 Ok((Some(highs), Some(lows)))
1203 }
1204
1205 fn extract_timestamps(
1206 &self,
1207 col: &Column,
1208 col_name: &str,
1209 ) -> Result<Vec<DateTime<Utc>>, BacktestError> {
1210 if let Ok(ca) = col.datetime() {
1212 return Ok(ca
1213 .into_iter()
1214 .map(|opt| {
1215 opt.map(|v| {
1216 let secs = v / 1000;
1218 let nanos = ((v % 1000) * 1_000_000) as u32;
1219 DateTime::<Utc>::from_timestamp(secs, nanos).unwrap_or_else(Utc::now)
1220 })
1221 .unwrap_or_else(Utc::now)
1222 })
1223 .collect());
1224 }
1225
1226 if let Ok(ca) = col.i64() {
1227 return Ok(ca
1229 .into_iter()
1230 .enumerate()
1231 .map(|(i, opt)| {
1232 let v = opt.unwrap_or(i as i64);
1233 DateTime::<Utc>::from_timestamp(v, 0).unwrap_or_else(Utc::now)
1234 })
1235 .collect());
1236 }
1237
1238 Err(BacktestError::InvalidDtype {
1239 col: col_name.to_string(),
1240 expected: "Datetime or Int64".into(),
1241 got: format!("{:?}", col.dtype()),
1242 })
1243 }
1244
1245 fn trades_to_df(
1246 &self,
1247 trades: &[Trade],
1248 include_symbol: bool,
1249 ) -> Result<DataFrame, PolarsError> {
1250 if trades.is_empty() {
1251 let mut cols = vec![
1252 Column::new("trade_id".into(), Vec::<u32>::new()),
1253 Column::new("side".into(), Vec::<i8>::new()),
1254 Column::new("entry_ts".into(), Vec::<i64>::new()),
1255 Column::new("entry_price".into(), Vec::<f64>::new()),
1256 Column::new("entry_fill_price".into(), Vec::<f64>::new()),
1257 Column::new("exit_ts".into(), Vec::<Option<i64>>::new()),
1258 Column::new("exit_price".into(), Vec::<Option<f64>>::new()),
1259 Column::new("exit_fill_price".into(), Vec::<Option<f64>>::new()),
1260 Column::new("quantity".into(), Vec::<f64>::new()),
1261 Column::new("pnl_net".into(), Vec::<f64>::new()),
1262 ];
1263 if include_symbol {
1264 cols.push(Column::new("symbol".into(), Vec::<Option<String>>::new()));
1265 }
1266 return DataFrame::new(cols);
1267 }
1268
1269 let ids: Vec<u32> = trades.iter().map(|t| t.trade_id).collect();
1270 let sides: Vec<i8> = trades.iter().map(|t| t.side).collect();
1271 let entry_ts: Vec<i64> = trades.iter().map(|t| t.entry_ts.timestamp()).collect();
1272 let entry_px: Vec<f64> = trades.iter().map(|t| t.entry_price).collect();
1273 let entry_fill_px: Vec<f64> = trades.iter().map(|t| t.entry_fill_price).collect();
1274 let exit_ts: Vec<Option<i64>> = trades
1275 .iter()
1276 .map(|t| t.exit_ts.map(|d| d.timestamp()))
1277 .collect();
1278 let exit_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_price).collect();
1279 let exit_fill_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_fill_price).collect();
1280 let qty: Vec<f64> = trades.iter().map(|t| t.quantity).collect();
1281 let pnl: Vec<f64> = trades.iter().map(|t| t.pnl_net).collect();
1282
1283 let mut cols = vec![
1284 Column::new("trade_id".into(), ids),
1285 Column::new("side".into(), sides),
1286 Column::new("entry_ts".into(), entry_ts),
1287 Column::new("entry_price".into(), entry_px),
1288 Column::new("entry_fill_price".into(), entry_fill_px),
1289 Column::new("exit_ts".into(), exit_ts),
1290 Column::new("exit_price".into(), exit_px),
1291 Column::new("exit_fill_price".into(), exit_fill_px),
1292 Column::new("quantity".into(), qty),
1293 Column::new("pnl_net".into(), pnl),
1294 ];
1295 if include_symbol {
1296 let symbols: Vec<Option<String>> = trades.iter().map(|t| t.symbol.clone()).collect();
1297 cols.push(Column::new("symbol".into(), symbols));
1298 }
1299
1300 DataFrame::new(cols)
1301 }
1302
1303 fn equity_to_df(
1304 &self,
1305 points: &[EquityPoint],
1306 include_symbol: bool,
1307 ) -> Result<DataFrame, PolarsError> {
1308 if points.is_empty() {
1309 let mut cols = vec![
1310 Column::new("ts".into(), Vec::<i64>::new()),
1311 Column::new("equity".into(), Vec::<f64>::new()),
1312 Column::new("cash".into(), Vec::<f64>::new()),
1313 Column::new("position".into(), Vec::<f64>::new()),
1314 Column::new("close".into(), Vec::<f64>::new()),
1315 ];
1316 if include_symbol {
1317 cols.push(Column::new("symbol".into(), Vec::<Option<String>>::new()));
1318 }
1319 return DataFrame::new(cols);
1320 }
1321
1322 let ts: Vec<i64> = points.iter().map(|p| p.ts.timestamp()).collect();
1323 let eq: Vec<f64> = points.iter().map(|p| p.equity).collect();
1324 let pos: Vec<f64> = points.iter().map(|p| p.position).collect();
1325 let cash: Vec<f64> = points.iter().map(|p| p.cash).collect();
1326 let close: Vec<f64> = points.iter().map(|p| p.close).collect();
1327
1328 let mut cols = vec![
1329 Column::new("ts".into(), ts),
1330 Column::new("equity".into(), eq),
1331 Column::new("cash".into(), cash),
1332 Column::new("position".into(), pos),
1333 Column::new("close".into(), close),
1334 ];
1335 if include_symbol {
1336 let symbols: Vec<Option<String>> = points.iter().map(|p| p.symbol.clone()).collect();
1337 cols.push(Column::new("symbol".into(), symbols));
1338 }
1339
1340 DataFrame::new(cols)
1341 }
1342}
1343
1344pub fn apply_signal_modifiers(
1347 raw_signal: f64,
1348 entry_filter: Option<bool>,
1349 size_multiplier: Option<f64>,
1350) -> f64 {
1351 if matches!(entry_filter, Some(false)) {
1352 return 0.0;
1353 }
1354 let mut exposure = raw_signal;
1355 if let Some(m) = size_multiplier {
1356 exposure *= m;
1357 }
1358 if exposure.is_finite() && exposure != 0.0 {
1359 exposure
1360 } else {
1361 0.0
1362 }
1363}
1364
1365fn extract_bool_column(col: Column, col_name: &str) -> Result<Vec<bool>, BacktestError> {
1366 if let Ok(ca) = col.bool() {
1367 return Ok(ca.into_iter().map(|opt| opt.unwrap_or(false)).collect());
1368 }
1369 Err(BacktestError::InvalidDtype {
1370 col: col_name.to_string(),
1371 expected: "Boolean".into(),
1372 got: format!("{:?}", col.dtype()),
1373 })
1374}
1375
1376fn extract_f64_column(col: Column, col_name: &str) -> Result<Vec<f64>, BacktestError> {
1377 if let Ok(ca) = col.f64() {
1378 return Ok(ca.into_iter().map(|opt| opt.unwrap_or(0.0)).collect());
1379 }
1380 Err(BacktestError::InvalidDtype {
1381 col: col_name.to_string(),
1382 expected: "Float64".into(),
1383 got: format!("{:?}", col.dtype()),
1384 })
1385}
1386
1387fn extract_string_column(col: Column, col_name: &str) -> Result<Vec<String>, BacktestError> {
1388 if let Ok(ca) = col.str() {
1389 return Ok(ca
1390 .into_iter()
1391 .map(|opt| opt.unwrap_or_default().to_string())
1392 .collect());
1393 }
1394 Err(BacktestError::InvalidDtype {
1395 col: col_name.to_string(),
1396 expected: "Utf8/String".into(),
1397 got: format!("{:?}", col.dtype()),
1398 })
1399}
1400
1401fn validate_sorted_timestamp_symbol(
1402 timestamps: &[DateTime<Utc>],
1403 symbols: &[String],
1404) -> Result<(), BacktestError> {
1405 if timestamps.len() != symbols.len() {
1406 return Err(BacktestError::InvalidInput("column length mismatch".into()));
1407 }
1408 for i in 1..timestamps.len() {
1409 let prev = (×tamps[i - 1], &symbols[i - 1]);
1410 let curr = (×tamps[i], &symbols[i]);
1411 if curr < prev {
1412 return Err(BacktestError::UnsortedData);
1413 }
1414 }
1415 Ok(())
1416}
1417
1418fn aggregate_portfolio_equity(per_symbol: &HashMap<String, Vec<EquityPoint>>) -> Vec<EquityPoint> {
1419 use std::collections::BTreeSet;
1420
1421 let mut ts_set = BTreeSet::new();
1422 for points in per_symbol.values() {
1423 for p in points {
1424 ts_set.insert(p.ts);
1425 }
1426 }
1427
1428 ts_set
1429 .into_iter()
1430 .map(|ts| {
1431 let mut total_equity = 0.0;
1432 let mut total_cash = 0.0;
1433 let mut total_position = 0.0;
1434 for points in per_symbol.values() {
1435 if let Some(p) = points.iter().find(|p| p.ts == ts) {
1436 total_equity += p.equity;
1437 total_cash += p.cash;
1438 total_position += p.position;
1439 }
1440 }
1441 EquityPoint {
1442 ts,
1443 symbol: None,
1444 equity: total_equity,
1445 cash: total_cash,
1446 position: total_position,
1447 close: 0.0,
1448 }
1449 })
1450 .collect()
1451}
1452
1453pub fn backtest_simple_bool_signal(
1456 ohlcv: DataFrame,
1457 signal_col: &str,
1458) -> Result<BacktestResult, BacktestError> {
1459 let config = BacktestConfig {
1460 signal_col: signal_col.to_string(),
1461 ..Default::default()
1462 };
1463 let engine = BacktestEngine::new(config);
1464 engine.run(ohlcv.lazy())
1465}
1466
1467fn run_simulation(
1475 timestamps: &[DateTime<Utc>],
1476 closes: &[f64],
1477 highs: Option<&[f64]>,
1478 lows: Option<&[f64]>,
1479 mut next_signal: impl FnMut(usize) -> (f64, Option<HashMap<String, f64>>),
1480 exec: &ExecutionModel,
1481 sizer: &Option<InitialRiskPositionSizer>,
1482 execution_delay: ExecutionDelay,
1483 stop_config: &StopConfig,
1484) -> (Vec<Trade>, Vec<EquityPoint>) {
1485 use stops::{OhlcBar, StopPositionState, evaluate_stops, trailing_level_at_entry};
1486 let mut cash = match exec {
1487 ExecutionModel::Simple(cm) => cm.initial_cash,
1488 ExecutionModel::HighFidelity { .. } => 100_000.0,
1489 };
1490 let mut current_exposure: f64 = 0.0;
1491 let mut entry_price: f64 = 0.0;
1492 let mut entry_ts: Option<DateTime<Utc>> = None;
1493 let mut entry_metadata: Option<HashMap<String, f64>> = None;
1494 let mut stop_state = StopPositionState::default();
1495 let mut need_signal_reset = false;
1496 let mut trade_id: u32 = 0;
1497 let mut trades: Vec<Trade> = Vec::new();
1498 let mut equity_points: Vec<EquityPoint> = Vec::with_capacity(closes.len());
1499
1500 let mut record_position_exit =
1501 |cash: &mut f64,
1502 tid: u32,
1503 side: i8,
1504 qty: f64,
1505 entry_px: f64,
1506 ets: DateTime<Utc>,
1507 exit_bar: usize,
1508 exit_raw_price: f64,
1509 meta: Option<HashMap<String, f64>>| {
1510 let is_buy = side == -1;
1512 let fill_price = exec.slippage_price(exit_raw_price, qty, is_buy, None);
1513 let notional = fill_price * qty;
1514 let cost = exec.commission_for(qty, fill_price);
1515 let gross_pnl = if side == 1 {
1516 (fill_price - entry_px) * qty
1517 } else {
1518 (entry_px - fill_price) * qty
1519 };
1520 let net_pnl = gross_pnl - cost;
1521 if side == 1 {
1522 *cash += notional - cost;
1523 } else {
1524 *cash -= notional + cost;
1525 }
1526 trades.push(Trade {
1527 trade_id: tid,
1528 symbol: None,
1529 side,
1530 entry_ts: ets,
1531 entry_price: entry_px,
1532 entry_fill_price: entry_px,
1533 exit_ts: Some(timestamps[exit_bar]),
1534 exit_price: Some(exit_raw_price),
1535 exit_fill_price: Some(fill_price),
1536 pnl_gross: gross_pnl,
1537 costs: cost,
1538 pnl_net: net_pnl,
1539 quantity: qty,
1540 entry_metadata: meta,
1541 });
1542 };
1543
1544 let open_position = |cash: &mut f64,
1545 tid: u32,
1546 desired: f64,
1547 fill_bar: usize,
1548 meta: Option<HashMap<String, f64>>|
1549 -> (
1550 u32,
1551 f64,
1552 f64,
1553 Option<DateTime<Utc>>,
1554 Option<HashMap<String, f64>>,
1555 Option<f64>,
1556 ) {
1557 let qty = desired.abs();
1558 let is_long = desired > 0.0;
1559 let is_buy = is_long;
1560 let close = closes[fill_bar];
1561 let fill_price = exec.slippage_price(close, qty, is_buy, None);
1562 let notional = fill_price * qty;
1563 let cost = exec.commission_for(qty, fill_price);
1564 if is_long {
1565 *cash -= notional + cost;
1566 } else {
1567 *cash += notional - cost;
1568 }
1569 let new_tid = tid + 1;
1570 let exposure = if is_long { qty } else { -qty };
1571 let trail = stop_config
1572 .trailing_stop_pct
1573 .map(|pct| trailing_level_at_entry(fill_price, is_long, pct));
1574 (
1575 new_tid,
1576 exposure,
1577 fill_price,
1578 Some(timestamps[fill_bar]),
1579 meta,
1580 trail,
1581 )
1582 };
1583
1584 for i in 0..closes.len() {
1585 let close = closes[i];
1586 let ohlc = OhlcBar {
1587 close,
1588 high: highs.and_then(|h| h.get(i).copied()),
1589 low: lows.and_then(|l| l.get(i).copied()),
1590 };
1591 if !close.is_finite() {
1592 let equity = cash + current_exposure * close;
1593 equity_points.push(EquityPoint {
1594 ts: timestamps[i],
1595 symbol: None,
1596 equity,
1597 cash,
1598 position: current_exposure,
1599 close,
1600 });
1601 continue;
1602 }
1603
1604 if current_exposure != 0.0 && stop_config.has_stops() {
1606 let is_long = current_exposure > 0.0;
1607 let qty = current_exposure.abs();
1608 if let Some(stop_exit) =
1609 evaluate_stops(stop_config, ohlc, is_long, entry_price, &mut stop_state)
1610 && let Some(ets) = entry_ts.take()
1611 {
1612 let side = if is_long { 1 } else { -1 };
1613 record_position_exit(
1614 &mut cash,
1615 trade_id,
1616 side,
1617 qty,
1618 entry_price,
1619 ets,
1620 i,
1621 stop_exit.exit_price,
1622 entry_metadata.clone(),
1623 );
1624 current_exposure = 0.0;
1625 entry_price = 0.0;
1626 stop_state = StopPositionState::default();
1627 entry_metadata = None;
1628 need_signal_reset = true;
1629 }
1630 }
1631
1632 let (raw_exposure, meta) = match signal_bar_index(i, execution_delay) {
1633 Some(si) => next_signal(si),
1634 None => (0.0, None),
1635 };
1636 let current_equity = cash + current_exposure * close;
1638 let desired_exposure = if let Some(s) = sizer {
1639 s.compute_sized_exposure(raw_exposure, &meta, close, current_equity)
1640 } else {
1641 raw_exposure
1642 };
1643 let desired = if desired_exposure.is_finite() && desired_exposure != 0.0 {
1644 desired_exposure
1645 } else {
1646 0.0
1647 };
1648
1649 if desired == 0.0 {
1650 need_signal_reset = false;
1651 }
1652
1653 let currently_in = current_exposure != 0.0;
1654
1655 if desired == 0.0 && currently_in {
1656 if let Some(ets) = entry_ts.take() {
1657 let side = if current_exposure > 0.0 { 1 } else { -1 };
1658 record_position_exit(
1659 &mut cash,
1660 trade_id,
1661 side,
1662 current_exposure.abs(),
1663 entry_price,
1664 ets,
1665 i,
1666 close,
1667 meta.clone(),
1668 );
1669 current_exposure = 0.0;
1670 entry_price = 0.0;
1671 stop_state = StopPositionState::default();
1672 entry_metadata = None;
1673 }
1674 } else if desired != 0.0 && !need_signal_reset {
1675 let want_long = desired > 0.0;
1676 let in_long = current_exposure > 0.0;
1677 let in_short = current_exposure < 0.0;
1678 let flip = (want_long && in_short) || (!want_long && in_long);
1679
1680 if flip && let Some(ets) = entry_ts.take() {
1681 let side = if in_long { 1 } else { -1 };
1682 record_position_exit(
1683 &mut cash,
1684 trade_id,
1685 side,
1686 current_exposure.abs(),
1687 entry_price,
1688 ets,
1689 i,
1690 close,
1691 entry_metadata.clone(),
1692 );
1693 current_exposure = 0.0;
1694 entry_price = 0.0;
1695 stop_state = StopPositionState::default();
1696 entry_metadata = None;
1697 }
1698
1699 if current_exposure == 0.0 {
1700 let (new_tid, exp, ep, ets, em, trail) =
1701 open_position(&mut cash, trade_id, desired, i, meta.clone());
1702 trade_id = new_tid;
1703 current_exposure = exp;
1704 entry_price = ep;
1705 entry_ts = ets;
1706 entry_metadata = em;
1707 stop_state.trailing_stop_level = trail;
1708 }
1709 }
1710
1711 let equity = cash + current_exposure * close;
1712 equity_points.push(EquityPoint {
1713 ts: timestamps[i],
1714 symbol: None,
1715 equity,
1716 cash,
1717 position: current_exposure,
1718 close,
1719 });
1720 }
1721
1722 if current_exposure != 0.0 {
1724 let last_close = closes[closes.len() - 1];
1725 let qty = current_exposure.abs();
1726 let side = if current_exposure > 0.0 { 1 } else { -1 };
1727 let gross = if side == 1 {
1728 (last_close - entry_price) * qty
1729 } else {
1730 (entry_price - last_close) * qty
1731 };
1732 if let Some(ets) = entry_ts {
1733 trades.push(Trade {
1734 trade_id,
1735 symbol: None,
1736 side,
1737 entry_ts: ets,
1738 entry_price,
1739 entry_fill_price: entry_price,
1740 exit_ts: None,
1741 exit_price: Some(last_close),
1742 exit_fill_price: None,
1743 pnl_gross: gross,
1744 costs: 0.0,
1745 pnl_net: gross,
1746 quantity: qty,
1747 entry_metadata: None,
1748 });
1749 }
1750 }
1751
1752 (trades, equity_points)
1753}
1754
1755pub fn run_streaming_simulation<G>(
1762 bars: &[Bar],
1763 mut generator: G,
1764 config: BacktestConfig,
1765) -> Result<BacktestResult, BacktestError>
1766where
1767 G: for<'a> Next<&'a Bar, Output = StrategySignal>,
1768{
1769 if bars.is_empty() {
1770 return Err(BacktestError::InvalidInput("empty bars".into()));
1771 }
1772
1773 let timestamps: Vec<DateTime<Utc>> = bars.iter().map(|b| b.ts).collect();
1774 let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
1775 let highs: Vec<f64> = bars.iter().map(|b| b.high.unwrap_or(b.close)).collect();
1776 let lows: Vec<f64> = bars.iter().map(|b| b.low.unwrap_or(b.close)).collect();
1777 let use_ohlc = config.stop_config.stop_evaluation == StopEvaluationMode::OhlcTouched;
1778
1779 let exec = &config.execution_model;
1780 let sizer = &config.position_sizer;
1781
1782 let delay = config.execution_delay;
1783 let stops = &config.stop_config;
1784 let (trades, equity_points) = run_simulation(
1785 ×tamps,
1786 &closes,
1787 if use_ohlc {
1788 Some(highs.as_slice())
1789 } else {
1790 None
1791 },
1792 if use_ohlc {
1793 Some(lows.as_slice())
1794 } else {
1795 None
1796 },
1797 |i| {
1798 let sig = generator.next(&bars[i]);
1799 (sig.exposure, sig.metadata.clone())
1800 },
1801 exec,
1802 sizer,
1803 delay,
1804 stops,
1805 );
1806
1807 let trades_df = if trades.is_empty() {
1812 DataFrame::new(vec![
1813 Column::new("trade_id".into(), Vec::<u32>::new()),
1814 Column::new("side".into(), Vec::<i8>::new()),
1815 Column::new("entry_ts".into(), Vec::<i64>::new()),
1816 Column::new("entry_price".into(), Vec::<f64>::new()),
1817 Column::new("pnl_net".into(), Vec::<f64>::new()),
1818 ])?
1819 } else {
1820 let ids: Vec<u32> = trades.iter().map(|t| t.trade_id).collect();
1821 let sides: Vec<i8> = trades.iter().map(|t| t.side).collect();
1822 let entry_ts: Vec<i64> = trades.iter().map(|t| t.entry_ts.timestamp()).collect();
1823 let entry_px: Vec<f64> = trades.iter().map(|t| t.entry_price).collect();
1824 let exit_ts: Vec<Option<i64>> = trades
1825 .iter()
1826 .map(|t| t.exit_ts.map(|d| d.timestamp()))
1827 .collect();
1828 let exit_px: Vec<Option<f64>> = trades.iter().map(|t| t.exit_price).collect();
1829 let pnl: Vec<f64> = trades.iter().map(|t| t.pnl_net).collect();
1830
1831 DataFrame::new(vec![
1832 Column::new("trade_id".into(), ids),
1833 Column::new("side".into(), sides),
1834 Column::new("entry_ts".into(), entry_ts),
1835 Column::new("entry_price".into(), entry_px),
1836 Column::new("exit_ts".into(), exit_ts),
1837 Column::new("exit_price".into(), exit_px),
1838 Column::new("pnl_net".into(), pnl),
1839 ])?
1840 };
1841
1842 let equity_df = if equity_points.is_empty() {
1843 DataFrame::new(vec![
1844 Column::new("ts".into(), Vec::<i64>::new()),
1845 Column::new("equity".into(), Vec::<f64>::new()),
1846 Column::new("position".into(), Vec::<f64>::new()),
1847 ])?
1848 } else {
1849 let ts: Vec<i64> = equity_points.iter().map(|p| p.ts.timestamp()).collect();
1850 let eq: Vec<f64> = equity_points.iter().map(|p| p.equity).collect();
1851 let pos: Vec<f64> = equity_points.iter().map(|p| p.position).collect();
1852 let cash: Vec<f64> = equity_points.iter().map(|p| p.cash).collect();
1853 let close: Vec<f64> = equity_points.iter().map(|p| p.close).collect();
1854
1855 DataFrame::new(vec![
1856 Column::new("ts".into(), ts),
1857 Column::new("equity".into(), eq),
1858 Column::new("cash".into(), cash),
1859 Column::new("position".into(), pos),
1860 Column::new("close".into(), close),
1861 ])?
1862 };
1863
1864 let initial_cash = match &config.execution_model {
1865 ExecutionModel::Simple(cm) => cm.initial_cash,
1866 _ => 100_000.0,
1867 };
1868 let final_equity = equity_points
1869 .last()
1870 .map(|e| e.equity)
1871 .unwrap_or(initial_cash);
1872 let total_return = (final_equity - initial_cash) / initial_cash;
1873 let num_trades = trades.len() as f64;
1874
1875 let mut stats = HashMap::new();
1876 stats.insert("initial_cash".to_string(), initial_cash);
1877 stats.insert("final_equity".to_string(), final_equity);
1878 stats.insert("total_return".to_string(), total_return);
1879 stats.insert("num_trades".to_string(), num_trades);
1880 stats.insert("net_pnl".to_string(), final_equity - initial_cash);
1881
1882 Ok(BacktestResult {
1883 trades: trades_df,
1884 equity_curve: equity_df,
1885 stats,
1886 })
1887}
1888
1889#[cfg(test)]
1890#[allow(clippy::panic)]
1891mod tests {
1892 use super::*;
1893 use approx::assert_relative_eq;
1894 use rand::Rng;
1896 use quantwave_core::features::CyberCycleFeatureExtractor;
1898 use quantwave_core::regimes::MarketRegime;
1899 use quantwave_core::regimes::tar::TAR;
1900 use quantwave_core::traits::Next;
1901 use std::collections::HashMap;
1902
1903 #[test]
1904 fn test_basic_long_only_flip_on_synthetic() {
1905 let n: usize = 6;
1908 let timestamps: Vec<i64> = (0..n)
1909 .map(|i| 1_700_000_000i64 + (i as i64) * 3600)
1910 .collect(); let closes = vec![100.0, 101.0, 102.5, 103.0, 102.0, 101.0];
1912 let signals = vec![0.0, 1.0, 1.0, 1.0, 0.0, 0.0];
1913
1914 let df = DataFrame::new(vec![
1915 Column::new("timestamp".into(), timestamps),
1916 Column::new("close".into(), closes.clone()),
1917 Column::new("signal".into(), signals),
1918 ])
1919 .unwrap();
1920
1921 let result = backtest_simple_bool_signal(df, "signal").expect("sim should succeed");
1922
1923 assert_eq!(result.trades.height(), 1);
1925 let num_trades: f64 = *result.stats.get("num_trades").unwrap();
1926 assert_relative_eq!(num_trades, 1.0, epsilon = 1e-9);
1927
1928 let final_eq = *result.stats.get("final_equity").unwrap();
1930 let init = 100_000.0;
1931 assert!(
1932 final_eq > init,
1933 "equity should grow on winning long: {} vs {}",
1934 final_eq,
1935 init
1936 );
1937
1938 assert_eq!(result.equity_curve.height(), n);
1940
1941 let last_equity = result
1943 .equity_curve
1944 .column("equity")
1945 .unwrap()
1946 .f64()
1947 .unwrap()
1948 .get(n - 1)
1949 .unwrap();
1950 assert_relative_eq!(last_equity, final_eq, epsilon = 1e-6);
1951 }
1952
1953 #[test]
1954 fn test_flat_always_signal_produces_no_trades_and_flat_equity() {
1955 let n: usize = 5;
1956 let ts: Vec<i64> = (0..n).map(|i| 1_700_000_100 + i as i64).collect();
1957 let closes = vec![100.0; n];
1958 let signals = vec![0.0; n];
1959
1960 let df = DataFrame::new(vec![
1961 Column::new("timestamp".into(), ts),
1962 Column::new("close".into(), closes),
1963 Column::new("signal".into(), signals),
1964 ])
1965 .unwrap();
1966
1967 let result = backtest_simple_bool_signal(df, "signal").unwrap();
1968
1969 assert_eq!(result.trades.height(), 0);
1970 let num = *result.stats.get("num_trades").unwrap();
1971 assert_relative_eq!(num, 0.0, epsilon = 1e-9);
1972
1973 let final_equity_val = *result.stats.get("final_equity").unwrap();
1975 assert_relative_eq!(final_equity_val, 100_000.0, epsilon = 1e-4);
1976 }
1977
1978 #[test]
1979 fn test_synthetic_with_small_random_walk_and_bool_signal_matches_manual_calc() {
1980 let mut rng = rand::thread_rng();
1982 let n: usize = 8;
1983 let mut price = 100.0_f64;
1984 let mut closes = Vec::with_capacity(n);
1985 let signals = vec![0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0]; let mut ts = Vec::with_capacity(n);
1987
1988 for i in 0..n {
1989 ts.push(1_700_000_200 + i as i64);
1990 closes.push(price);
1991 price += rng.gen_range(-0.8..1.2);
1992 }
1993
1994 let df = DataFrame::new(vec![
1995 Column::new("timestamp".into(), ts.clone()),
1996 Column::new("close".into(), closes.clone()),
1997 Column::new("signal".into(), signals.clone()),
1998 ])
1999 .unwrap();
2000
2001 let result = backtest_simple_bool_signal(df.clone(), "signal").unwrap();
2002
2003 let slip = 0.0002;
2005 let comm = 0.0005;
2006 let init = 100_000.0;
2007 let mut cash = init;
2008 let mut pos = 0.0;
2009 let mut entry = 0.0;
2010 let mut manual_equity = init;
2011
2012 for i in 0..n {
2013 let c = closes[i];
2014 let s = signals[i] > 0.0;
2015
2016 if s && pos == 0.0 {
2017 let fp = c * (1.0 + slip);
2018 cash -= fp * (1.0 + comm);
2019 pos = 1.0;
2020 entry = fp;
2021 } else if !s && pos > 0.0 {
2022 let fp = c * (1.0 - slip);
2023 cash += fp * (1.0 - comm);
2024 let _g = (fp - entry) * pos;
2025 let cost = fp * comm;
2026 cash += -cost; pos = 0.0;
2028 }
2029 manual_equity = cash + pos * c;
2030 }
2031
2032 let engine_final = *result.stats.get("final_equity").unwrap();
2033 assert_relative_eq!(engine_final, manual_equity, epsilon = 0.5);
2035 }
2036
2037 #[derive(Debug, Clone)]
2045 struct SyntheticPoleHeightDetector {
2046 window: Vec<f64>,
2047 max_len: usize,
2048 }
2049
2050 impl SyntheticPoleHeightDetector {
2051 fn new(max_len: usize) -> Self {
2052 Self {
2053 window: Vec::with_capacity(max_len),
2054 max_len,
2055 }
2056 }
2057 }
2058
2059 #[derive(Debug, Clone, Copy)]
2060 struct PoleOutput {
2061 pole_height: f64,
2062 _strength: f64, }
2064
2065 impl Next<f64> for SyntheticPoleHeightDetector {
2066 type Output = PoleOutput;
2067
2068 fn next(&mut self, price: f64) -> PoleOutput {
2069 self.window.push(price);
2070 if self.window.len() > self.max_len {
2071 self.window.remove(0);
2072 }
2073 let h = if self.window.len() >= 3 {
2074 let mn = self.window.iter().fold(f64::INFINITY, |a, &b| a.min(b));
2075 let mx = self.window.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
2076 (mx - mn).max(0.1)
2077 } else {
2078 1.0
2079 };
2080 PoleOutput {
2081 pole_height: h,
2082 _strength: (h / 8.0).clamp(0.3, 1.0),
2083 }
2084 }
2085 }
2086
2087 #[derive(Debug, Clone)]
2091 struct RegimeFeaturePAStrategy {
2092 regime: TAR,
2093 cycle: CyberCycleFeatureExtractor,
2094 pa: SyntheticPoleHeightDetector,
2095 feat_thresh: f64,
2096 }
2097
2098 impl RegimeFeaturePAStrategy {
2099 fn new() -> Self {
2100 Self {
2101 regime: TAR::new(105.0), cycle: CyberCycleFeatureExtractor::new(14),
2103 pa: SyntheticPoleHeightDetector::new(6),
2104 feat_thresh: 0.02,
2105 }
2106 }
2107 }
2108
2109 impl Next<&Bar> for RegimeFeaturePAStrategy {
2110 type Output = StrategySignal;
2111
2112 fn next(&mut self, bar: &Bar) -> StrategySignal {
2113 let regime = self.regime.next(bar.close);
2114 let feat = self.cycle.next(bar.close);
2115 let pa = self.pa.next(bar.close);
2116
2117 let regime_ok = matches!(
2119 regime,
2120 MarketRegime::Steady | MarketRegime::Cluster(_) | MarketRegime::Bull
2121 );
2122 let feat_ok = feat.cycle_momentum.abs() > self.feat_thresh;
2123
2124 let exposure = if regime_ok && feat_ok {
2125 (pa.pole_height / 4.0).clamp(0.4, 2.2)
2127 } else {
2128 0.0
2129 };
2130
2131 let mut meta = HashMap::new();
2132 meta.insert("pole_height".to_string(), pa.pole_height);
2133 meta.insert("cycle_momentum".to_string(), feat.cycle_momentum);
2134 meta.insert("regime_ok".to_string(), if regime_ok { 1.0 } else { 0.0 });
2135
2136 StrategySignal {
2137 exposure,
2138 metadata: Some(meta),
2139 }
2140 }
2141 }
2142
2143 #[test]
2144 fn test_batch_vs_streaming_parity_regime_feature_rich_pa_pole_sizing() {
2145 let n: usize = 120;
2148 let mut timestamps = Vec::with_capacity(n);
2149 let mut closes = Vec::with_capacity(n);
2150 let mut price;
2151
2152 for i in 0..n {
2153 let secs = 1_700_000_500i64 + (i as i64) * 3600;
2154 timestamps.push(chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0).unwrap());
2155 let wave = (i as f64 * 0.18).sin() * 4.5;
2157 price = 101.5 + wave + (i as f64 * 0.008);
2158 closes.push(price);
2159 }
2160
2161 let bars: Vec<Bar> = timestamps
2162 .iter()
2163 .zip(closes.iter())
2164 .map(|(&ts, &close)| Bar {
2165 ts,
2166 close,
2167 high: None,
2168 low: None,
2169 })
2170 .collect();
2171
2172 let mut batch_gen = RegimeFeaturePAStrategy::new();
2176 let mut exposures: Vec<f64> = Vec::with_capacity(n);
2177 for bar in &bars {
2178 let s = batch_gen.next(bar);
2179 exposures.push(s.exposure);
2180 }
2181
2182 let df = DataFrame::new(vec![
2183 Column::new(
2184 "timestamp".into(),
2185 timestamps.iter().map(|t| t.timestamp()).collect::<Vec<_>>(),
2186 ),
2187 Column::new("close".into(), closes.clone()),
2188 Column::new("signal".into(), exposures.clone()),
2189 ])
2190 .unwrap();
2191
2192 let batch_res = backtest_simple_bool_signal(df, "signal").expect("batch parity run");
2193
2194 let stream_gen = RegimeFeaturePAStrategy::new();
2196 let stream_res = run_streaming_simulation(&bars, stream_gen, BacktestConfig::default())
2197 .expect("streaming parity run");
2198
2199 let b_eq = batch_res
2202 .equity_curve
2203 .column("equity")
2204 .unwrap()
2205 .f64()
2206 .unwrap()
2207 .into_iter()
2208 .map(|v| v.unwrap_or(0.0))
2209 .collect::<Vec<_>>();
2210 let s_eq = stream_res
2211 .equity_curve
2212 .column("equity")
2213 .unwrap()
2214 .f64()
2215 .unwrap()
2216 .into_iter()
2217 .map(|v| v.unwrap_or(0.0))
2218 .collect::<Vec<_>>();
2219
2220 assert_eq!(b_eq.len(), s_eq.len(), "equity curve lengths must match");
2221 for (i, (b, s)) in b_eq.iter().zip(s_eq.iter()).enumerate() {
2222 approx::assert_relative_eq!(*b, *s, epsilon = 1e-8, max_relative = 1e-8);
2223 if (b - s).abs() > 1e-7 {
2225 panic!("equity diverged at bar {}: {} vs {}", i, b, s);
2226 }
2227 }
2228
2229 let keys = ["final_equity", "net_pnl", "num_trades"];
2231 for k in keys {
2232 let bv = *batch_res.stats.get(k).unwrap();
2233 let sv = *stream_res.stats.get(k).unwrap();
2234 approx::assert_relative_eq!(bv, sv, epsilon = 1e-6, max_relative = 1e-6);
2235 }
2236
2237 assert_eq!(
2239 batch_res.trades.height(),
2240 stream_res.trades.height(),
2241 "trade counts must match exactly for parity"
2242 );
2243
2244 assert!(
2247 batch_res.trades.height() >= 1,
2248 "parity test strategy must generate >=1 trade on synthetic data"
2249 );
2250
2251 }
2256}
2257
2258#[cfg(test)]
2267#[allow(clippy::panic)]
2268mod integration_example_between_epics {
2269 use super::*;
2270 use quantwave_core::features::HurstFeatureExtractor;
2272
2273 #[test]
2274 fn ml_features_feed_backtester_with_metadata() {
2275 let n = 60;
2276 let closes: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 * 0.25).collect();
2277 let timestamps: Vec<i64> = (0..n).map(|i| 1_700_000_000i64 + i as i64).collect();
2279
2280 let mut h_ext = HurstFeatureExtractor::new(15);
2282 let mut exposures = Vec::new();
2283
2284 for &c in &closes {
2285 let f = h_ext.next(c);
2286 let regime_ok = true; let exposure = if regime_ok && f.persistence > 0.52 {
2288 1.0
2289 } else {
2290 0.0
2291 };
2292 exposures.push(exposure);
2293 }
2294
2295 let lf = df![
2297 "timestamp" => timestamps,
2298 "close" => closes,
2299 "exposure" => exposures,
2300 ]
2301 .unwrap()
2302 .lazy();
2303
2304 let config = BacktestConfig {
2305 signal_col: "exposure".to_string(),
2306 ..Default::default()
2307 };
2308
2309 let result = BacktestEngine::new(config).run(lf).unwrap();
2310
2311 println!(
2313 "Integration smoke test: {} trades produced using ML feature (Hurst) driven exposure",
2314 result.trades.height()
2315 );
2316 assert!(result.equity_curve.height() == n);
2317 }
2318
2319 #[test]
2320 fn test_initial_risk_position_sizer_with_pole_height_and_fraction() {
2321 let sizer = InitialRiskPositionSizer {
2323 initial_risk: 0.01,
2324 max_target_pct: 0.5,
2325 };
2326 let mut meta = HashMap::new();
2327 meta.insert("pole_height_atr".to_string(), 2.0); let sig = StrategySignal {
2329 exposure: 1.0,
2330 metadata: Some(meta),
2331 };
2332 let sized = sizer.compute_sized_exposure(1.0, &sig.metadata, 100.0, 1_000_000.0);
2333 assert!((sized - 5000.0).abs() < 1.0);
2336
2337 let mut meta2 = HashMap::new();
2339 meta2.insert("fraction_at_risk".to_string(), 0.02);
2340 let sig2 = StrategySignal {
2341 exposure: 1.0,
2342 metadata: Some(meta2),
2343 };
2344 let sized2 = sizer.compute_sized_exposure(1.0, &sig2.metadata, 100.0, 1_000_000.0);
2345 assert!((sized2 - 5000.0).abs() < 1.0);
2347
2348 let sig3 = StrategySignal {
2350 exposure: 123.0,
2351 metadata: None,
2352 };
2353 let sized3 = sizer.compute_sized_exposure(123.0, &sig3.metadata, 100.0, 1_000_000.0);
2354 assert!((sized3 - 123.0).abs() < 1e-9);
2355 }
2356}