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};
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; use serde::{Deserialize, Serialize};
110use std::collections::HashMap;
111use thiserror::Error;
112
113#[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#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct CostModel {
129 pub commission_bps: f64,
131 pub slippage_bps: f64,
133 pub initial_cash: f64,
135}
136
137impl Default for CostModel {
138 fn default() -> Self {
139 Self {
140 commission_bps: 5.0, slippage_bps: 2.0, initial_cash: 100_000.0,
143 }
144 }
145}
146
147pub 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 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
175pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
209pub enum ExecutionDelay {
210 #[default]
212 SameBar,
213 NextBar,
215}
216
217#[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#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct InitialRiskPositionSizer {
257 pub initial_risk: f64,
259 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 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 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 if let Some(pole) = m.get("pole_height_atr").copied() {
292 if pole > 0.0 {
293 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#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct BacktestConfig {
308 pub cost_model: CostModel,
309 pub timestamp_col: String,
311 pub symbol_col: Option<String>,
312 pub close_col: String,
313 pub high_col: Option<String>,
315 pub low_col: Option<String>,
317 pub signal_col: String,
324 pub entry_filter_col: Option<String>,
328 pub size_multiplier_col: Option<String>,
331
332 pub execution_model: ExecutionModel,
334 pub execution_delay: ExecutionDelay,
336 pub stop_config: StopConfig,
338 pub position_sizer: Option<InitialRiskPositionSizer>,
341 pub portfolio_mode: PortfolioMode,
343 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
369fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct Trade {
380 pub trade_id: u32,
381 pub symbol: Option<String>,
382 pub side: i8, pub entry_ts: DateTime<Utc>,
384 pub entry_price: f64,
385 pub entry_fill_price: f64, 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 pub quantity: f64,
395 pub entry_metadata: Option<HashMap<String, f64>>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct EquityPoint {
403 pub ts: DateTime<Utc>,
404 pub symbol: Option<String>, pub equity: f64,
406 pub cash: f64,
407 pub position: f64, pub close: f64,
409}
410
411#[derive(Debug)]
413pub struct BacktestResult {
414 pub trades: DataFrame,
416 pub equity_curve: DataFrame,
418 pub stats: HashMap<String, f64>,
420}
421
422impl BacktestResult {
423 pub fn metrics(&self) -> PerformanceMetrics {
425 PerformanceMetrics::from_result(self)
426 }
427}
428
429#[derive(Debug, Clone)]
431pub struct Bar {
432 pub ts: DateTime<Utc>,
433 pub close: f64,
434 pub high: Option<f64>,
436 pub low: Option<f64>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct StrategySignal {
445 pub exposure: f64,
447 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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
465pub struct PAEvent {
466 pub long: bool,
468 pub pole_height: Option<f64>,
470 pub strength: Option<f64>,
472}
473
474impl PAEvent {
475 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
498pub fn pole_height_to_exposure(pole_height: f64) -> f64 {
500 (pole_height / 4.0).clamp(0.4, 2.2)
501}
502
503pub 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
573pub 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 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 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(×tamps, &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(×tamps, &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(×tamps, &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 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 ×tamps,
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 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); 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 ×tamps,
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 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 let s = col
1169 .as_series()
1170 .ok_or_else(|| BacktestError::InvalidInput("column has no series backing".into()))?;
1171
1172 if let Ok(ca) = s.datetime() {
1174 return Ok(ca
1175 .into_iter()
1176 .map(|opt| {
1177 opt.map(|v| {
1178 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 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 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
1297pub 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 = (×tamps[i - 1], &symbols[i - 1]);
1369 let curr = (×tamps[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
1412pub 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
1426fn 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 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 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 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 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
1710pub 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 ×tamps,
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 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 rand::Rng;
1850 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 let n: usize = 6;
1862 let timestamps: Vec<i64> = (0..n)
1863 .map(|i| 1_700_000_000i64 + (i as i64) * 3600)
1864 .collect(); 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 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 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 assert_eq!(result.equity_curve.height(), n);
1894
1895 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 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 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]; 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 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; pos = 0.0;
1982 }
1983 manual_equity = cash + pos * c;
1984 }
1985
1986 let engine_final = *result.stats.get("final_equity").unwrap();
1987 assert_relative_eq!(engine_final, manual_equity, epsilon = 0.5);
1989 }
1990
1991 #[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, }
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 #[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), 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 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 (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 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 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 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 let stream_gen = RegimeFeaturePAStrategy::new();
2150 let stream_res = run_streaming_simulation(&bars, stream_gen, BacktestConfig::default())
2151 .expect("streaming parity run");
2152
2153 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 if (b - s).abs() > 1e-7 {
2179 panic!("equity diverged at bar {}: {} vs {}", i, b, s);
2180 }
2181 }
2182
2183 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 assert_eq!(
2193 batch_res.trades.height(),
2194 stream_res.trades.height(),
2195 "trade counts must match exactly for parity"
2196 );
2197
2198 assert!(
2201 batch_res.trades.height() >= 1,
2202 "parity test strategy must generate >=1 trade on synthetic data"
2203 );
2204
2205 }
2210}
2211
2212#[cfg(test)]
2221mod integration_example_between_epics {
2222 use super::*;
2223 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 let timestamps: Vec<i64> = (0..n).map(|i| 1_700_000_000i64 + i as i64).collect();
2232
2233 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; 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 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 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 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); 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 assert!((sized - 5000.0).abs() < 1.0);
2283
2284 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 assert!((sized2 - 5000.0).abs() < 1.0);
2291
2292 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}