1use std::collections::BTreeMap;
17use std::sync::Arc;
18
19use chrono::{DateTime, TimeZone, Utc};
20use rustrade_core::{
21 Brain, Candle, Decision, Exchange, Fill, MarketDataEvent, OrderKind, Position, Side,
22 SignalType, SizeHint, Symbol,
23};
24use rustrade_risk::PositionSizer;
25
26use crate::config::BacktestConfig;
27use crate::error::{Error, Result};
28use crate::metrics::TradeOutcome;
29use crate::result::BacktestResult;
30
31pub struct Backtest {
58 config: BacktestConfig,
59 brain: Arc<dyn Brain>,
60 series: Vec<(Symbol, Vec<Candle>)>,
64}
65
66impl Backtest {
67 pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
70 Self {
71 config,
72 brain,
73 series: Vec::new(),
74 }
75 }
76
77 pub fn with_candles(mut self, candles: Vec<Candle>) -> Self {
83 assert_eq!(
84 self.config.symbols.len(),
85 1,
86 "Backtest::with_candles requires a single-symbol config; \
87 this config has {} symbols. Use Backtest::with_symbol_candles instead.",
88 self.config.symbols.len()
89 );
90 let symbol = self.config.symbols[0].clone();
91 self.series = vec![(symbol, candles)];
92 self
93 }
94
95 pub fn with_symbol_candles(mut self, symbol: impl Into<Symbol>, candles: Vec<Candle>) -> Self {
102 let symbol = symbol.into();
103 self.series.retain(|(s, _)| s != &symbol);
104 self.series.push((symbol, candles));
105 self
106 }
107
108 pub async fn run(self) -> Result<BacktestResult> {
110 let exchange = Exchange::from("backtest");
111 let sizer = PositionSizer::new(self.config.sizing.clone());
112
113 let merged = merge_series(&self.series);
114 let candles_processed = merged.len();
115
116 for (symbol, candle) in &merged {
121 if let Err(why) = validate_candle(candle) {
122 return Err(Error::Data(format!(
123 "{symbol} candle at t={}: {why}",
124 candle.time
125 )));
126 }
127 }
128
129 let mut state = State::new(
130 self.config.initial_cash,
131 self.config.symbols.iter().cloned(),
132 );
133 let mut signals_emitted = 0usize;
134 let mut orders_filled = 0usize;
135 let mut trades: Vec<TradeOutcome> = Vec::new();
136
137 for (symbol, candle) in &merged {
138 let event = MarketDataEvent::Candle {
139 exchange: exchange.clone(),
140 symbol: symbol.clone(),
141 candle: *candle,
142 };
143
144 let position = state.position(symbol).copied().unwrap_or(Position::FLAT);
150 let decision = self
151 .brain
152 .on_event(&event, &position)
153 .await
154 .map_err(|e| Error::Brain(e.to_string()))?;
155
156 let in_config = state.has_symbol(symbol);
157
158 if !in_config || matches!(decision.signal, SignalType::Hold) {
159 state.sample_step(symbol, candle.close, self.config.contract_value);
160 continue;
161 }
162 signals_emitted += 1;
163
164 let Some(resolved) = resolve_order(
169 &decision,
170 &position,
171 &sizer,
172 candle.close,
173 self.config.contract_value,
174 ) else {
175 state.sample_step(symbol, candle.close, self.config.contract_value);
176 continue;
177 };
178 if resolved.qty <= 0.0 {
179 state.sample_step(symbol, candle.close, self.config.contract_value);
180 continue;
181 }
182
183 let Some((reference_price, is_taker)) = resolve_fill(&resolved, candle) else {
191 state.sample_step(symbol, candle.close, self.config.contract_value);
192 continue;
193 };
194 let fill_price = if is_taker {
197 self.config.slippage.apply(resolved.side, reference_price)
198 } else {
199 reference_price
200 };
201 let fee = self.config.fees.fee_for(
202 fill_price,
203 resolved.qty * self.config.contract_value,
204 is_taker,
205 );
206
207 apply_fill(
210 &mut state,
211 symbol,
212 resolved.side,
213 resolved.qty,
214 fill_price,
215 fee,
216 self.config.contract_value,
217 candle_time(candle),
218 &mut trades,
219 );
220
221 orders_filled += 1;
222
223 let fill = Fill {
226 symbol: symbol.clone(),
227 order_id: format!("bt-{orders_filled}"),
228 client_id: None,
229 side: resolved.side,
230 price: rustrade_core::Price(fill_price),
231 size: rustrade_core::Volume(resolved.qty),
232 fee,
233 fee_currency: "QUOTE".into(),
234 timestamp: candle_time(candle),
235 };
236 self.brain
237 .on_fill(&fill)
238 .await
239 .map_err(|e| Error::Brain(e.to_string()))?;
240
241 state.sample_step(symbol, candle.close, self.config.contract_value);
242 }
243
244 let total_fees: f64 = trades.iter().map(|t| t.fee).sum();
245 let net_pnl: f64 = trades.iter().map(|t| t.net_pnl()).sum();
246 let symbol_label = if self.config.symbols.len() == 1 {
247 self.config.symbols[0].as_str().to_string()
248 } else {
249 let parts: Vec<&str> = self.config.symbols.iter().map(|s| s.as_str()).collect();
251 parts.join(",")
252 };
253
254 let returns = state.into_returns();
255 Ok(BacktestResult {
256 symbol: symbol_label,
257 initial_cash: self.config.initial_cash,
258 final_cash: self.config.initial_cash + net_pnl,
259 net_pnl,
260 total_fees,
261 candles_processed,
262 signals_emitted,
263 orders_filled,
264 trades,
265 max_drawdown: returns.max_drawdown,
266 equity_curve: returns.equity,
267 period_returns: returns.period_returns,
268 risk_free_rate: self.config.risk_free_rate,
269 periods_per_year: self.config.periods_per_year,
270 })
271 }
272}
273
274fn merge_series(series: &[(Symbol, Vec<Candle>)]) -> Vec<(Symbol, Candle)> {
282 let total: usize = series.iter().map(|(_, c)| c.len()).sum();
283 let mut out: Vec<(Symbol, Candle, usize)> = Vec::with_capacity(total);
284 for (series_idx, (sym, candles)) in series.iter().enumerate() {
285 for c in candles {
286 out.push((sym.clone(), *c, series_idx));
287 }
288 }
289 out.sort_by(|a, b| a.1.time.cmp(&b.1.time).then(a.2.cmp(&b.2)));
292 out.into_iter().map(|(s, c, _)| (s, c)).collect()
293}
294
295struct State {
301 positions: BTreeMap<Symbol, Position>,
309 cash: f64,
310 equity_hwm: f64,
311 max_drawdown: f64,
312 last_equity: f64,
315 equity_curve: Vec<f64>,
316 period_returns: Vec<f64>,
317 last_marks: BTreeMap<Symbol, f64>,
322}
323
324struct ReturnsSummary {
325 max_drawdown: f64,
326 equity: Vec<f64>,
327 period_returns: Vec<f64>,
328}
329
330impl State {
331 fn new(initial_cash: f64, symbols: impl IntoIterator<Item = Symbol>) -> Self {
332 let mut positions = BTreeMap::new();
333 for s in symbols {
334 positions.insert(s, Position::FLAT);
335 }
336 Self {
337 positions,
338 cash: initial_cash,
339 equity_hwm: initial_cash,
340 max_drawdown: 0.0,
341 last_equity: initial_cash,
342 equity_curve: vec![initial_cash],
343 period_returns: Vec::new(),
344 last_marks: BTreeMap::new(),
345 }
346 }
347
348 fn has_symbol(&self, sym: &Symbol) -> bool {
349 self.positions.contains_key(sym)
350 }
351
352 fn position(&self, sym: &Symbol) -> Option<&Position> {
353 self.positions.get(sym)
354 }
355
356 fn position_mut(&mut self, sym: &Symbol) -> &mut Position {
357 self.positions.entry(sym.clone()).or_insert(Position::FLAT)
358 }
359
360 fn sample_step(&mut self, sym: &Symbol, close: f64, contract_value: f64) {
364 self.last_marks.insert(sym.clone(), close);
365 let equity = self.equity_now(contract_value);
366
367 if equity > self.equity_hwm {
369 self.equity_hwm = equity;
370 }
371 let dd = equity - self.equity_hwm;
372 if dd < self.max_drawdown {
373 self.max_drawdown = dd;
374 }
375
376 self.equity_curve.push(equity);
377 let prev = self.last_equity;
381 if prev > 0.0 {
382 self.period_returns.push((equity - prev) / prev);
383 } else {
384 self.period_returns.push(0.0);
385 }
386 self.last_equity = equity;
387 }
388
389 fn equity_now(&self, contract_value: f64) -> f64 {
392 let mut equity = self.cash;
393 for (sym, pos) in &self.positions {
394 if let Some(entry) = pos.entry_price
395 && let Some(mark) = self.last_marks.get(sym)
396 {
397 let pnl_per_unit = (mark - entry) * pos.qty.signum();
398 equity += pnl_per_unit * pos.qty.abs() * contract_value;
399 }
400 }
401 equity
402 }
403
404 fn into_returns(self) -> ReturnsSummary {
405 ReturnsSummary {
406 max_drawdown: self.max_drawdown,
407 equity: self.equity_curve,
408 period_returns: self.period_returns,
409 }
410 }
411}
412
413pub(crate) fn validate_candle(c: &Candle) -> std::result::Result<(), String> {
421 for (name, v) in [
422 ("open", c.open),
423 ("high", c.high),
424 ("low", c.low),
425 ("close", c.close),
426 ] {
427 if !v.is_finite() || v <= 0.0 {
428 return Err(format!("{name}={v} (prices must be finite and > 0)"));
429 }
430 }
431 if !c.volume.is_finite() || c.volume < 0.0 {
432 return Err(format!("volume={} (must be finite and >= 0)", c.volume));
433 }
434 Ok(())
435}
436
437struct ResolvedOrder {
439 side: Side,
440 qty: f64,
441 is_close: bool,
442 kind: OrderKind,
443 limit_price: Option<f64>,
446}
447
448fn resolve_order(
450 decision: &Decision,
451 position: &Position,
452 sizer: &PositionSizer,
453 price: f64,
454 contract_value: f64,
455) -> Option<ResolvedOrder> {
456 match decision.signal {
457 SignalType::Hold => None,
458 SignalType::Close => {
459 let close_side = position.close_side()?;
460 Some(ResolvedOrder {
461 side: close_side,
462 qty: position.qty.abs(),
463 is_close: true,
464 kind: OrderKind::Market,
465 limit_price: None,
466 })
467 }
468 SignalType::Buy | SignalType::Sell => {
469 let side = if matches!(decision.signal, SignalType::Buy) {
470 Side::Buy
471 } else {
472 Side::Sell
473 };
474 let contracts = size_from_hint(sizer, decision.size_hint, price, contract_value);
475 if contracts == 0 {
476 None
477 } else {
478 Some(ResolvedOrder {
479 side,
480 qty: contracts as f64,
481 is_close: false,
482 kind: decision.order_kind,
483 limit_price: decision.limit_price.map(|p| p.value()),
484 })
485 }
486 }
487 }
488}
489
490fn resolve_fill(resolved: &ResolvedOrder, candle: &Candle) -> Option<(f64, bool)> {
504 if resolved.is_close
505 || matches!(
506 resolved.kind,
507 OrderKind::Market | OrderKind::Ioc | OrderKind::Fok
508 )
509 {
510 return Some((candle.close, true));
511 }
512
513 let limit = resolved.limit_price.unwrap_or(candle.close);
514 let (fills, price, marketable) = match resolved.side {
515 Side::Buy => (
516 candle.low <= limit,
517 limit.min(candle.open),
518 limit >= candle.open,
519 ),
520 Side::Sell => (
521 candle.high >= limit,
522 limit.max(candle.open),
523 limit <= candle.open,
524 ),
525 };
526 if !fills {
527 return None;
528 }
529 if matches!(resolved.kind, OrderKind::PostOnly) && marketable {
530 return None;
531 }
532 Some((price, marketable))
533}
534
535fn size_from_hint(sizer: &PositionSizer, hint: SizeHint, price: f64, contract_value: f64) -> u32 {
536 match hint {
537 SizeHint::Default => sizer.contracts(price, contract_value),
538 SizeHint::MarginFraction(f) => {
539 let f = f.clamp(0.0, 1.0);
540 let margin = sizer.config().margin_per_trade * f;
541 sizer.contracts_with_margin(margin, price, contract_value)
542 }
543 SizeHint::NotionalUsd(n) => {
544 let leverage = sizer.config().leverage.max(1);
545 let margin = n / f64::from(leverage);
546 sizer.contracts_with_margin(margin, price, contract_value)
547 }
548 SizeHint::Quantity(q) => {
549 let raw = q.value().max(0.0).floor() as u32;
550 raw.min(sizer.config().max_contracts)
551 }
552 }
553}
554
555#[allow(clippy::too_many_arguments)]
558fn apply_fill(
559 state: &mut State,
560 symbol: &Symbol,
561 side: Side,
562 qty: f64,
563 fill_price: f64,
564 fee: f64,
565 contract_value: f64,
566 when: DateTime<Utc>,
567 trades: &mut Vec<TradeOutcome>,
568) {
569 let signed_qty = match side {
571 Side::Buy => qty,
572 Side::Sell => -qty,
573 };
574
575 let (old_qty, old_entry) = {
576 let p = state.position_mut(symbol);
577 (p.qty, p.entry_price)
578 };
579 let new_qty = old_qty + signed_qty;
580
581 let closing_qty = if old_qty.signum() != signed_qty.signum() && old_qty != 0.0 {
585 old_qty.abs().min(qty)
586 } else {
587 0.0
588 };
589 let opening_qty = qty - closing_qty;
590
591 if closing_qty > 0.0 {
592 let entry = old_entry.unwrap_or(fill_price);
593 let direction = old_qty.signum();
594 let gross = (fill_price - entry) * direction * closing_qty * contract_value;
595 let fee_share = if qty > 0.0 {
598 fee * (closing_qty / qty)
599 } else {
600 0.0
601 };
602 trades.push(TradeOutcome {
603 symbol: symbol.as_str().to_string(),
604 close_side: side,
605 qty: closing_qty,
606 entry_price: entry,
607 exit_price: fill_price,
608 gross_pnl: gross,
609 fee: fee_share,
610 closed_at: when,
611 });
612 state.cash += gross - fee_share;
613 }
614
615 let new_position = if opening_qty > 0.0 {
616 let fee_open = if qty > 0.0 {
618 fee * (opening_qty / qty)
619 } else {
620 0.0
621 };
622 state.cash -= fee_open;
623 let new_position_qty_after_close = old_qty + side_sign(side) * closing_qty;
629 let post_open_qty = new_position_qty_after_close + side_sign(side) * opening_qty;
630 let entry = if new_position_qty_after_close == 0.0 {
631 fill_price
632 } else {
633 let prev_entry = old_entry.unwrap_or(fill_price);
634 let prev_notional = prev_entry * new_position_qty_after_close.abs();
635 let new_notional = fill_price * opening_qty;
636 (prev_notional + new_notional) / post_open_qty.abs()
637 };
638 Position {
639 qty: post_open_qty,
640 entry_price: Some(entry),
641 unrealised_pnl: 0.0,
642 }
643 } else if new_qty == 0.0 {
644 Position::FLAT
645 } else {
646 Position {
647 qty: new_qty,
648 entry_price: old_entry,
649 unrealised_pnl: 0.0,
650 }
651 };
652 *state.position_mut(symbol) = new_position;
653}
654
655fn side_sign(side: Side) -> f64 {
656 match side {
657 Side::Buy => 1.0,
658 Side::Sell => -1.0,
659 }
660}
661
662fn candle_time(c: &Candle) -> DateTime<Utc> {
663 Utc.timestamp_millis_opt(c.time)
664 .single()
665 .unwrap_or_else(Utc::now)
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use async_trait::async_trait;
672 use rustrade_core::{BrainHealth, Decision, MarketDataEvent, Position, Result as CoreResult};
673 use rustrade_risk::SizingConfig;
674
675 struct FixedBrain {
677 signal: SignalType,
678 }
679 #[async_trait]
680 impl Brain for FixedBrain {
681 fn name(&self) -> &str {
682 "fixed"
683 }
684 async fn on_event(&self, _e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
685 Ok(match self.signal {
686 SignalType::Hold => Decision::hold(),
687 SignalType::Buy => Decision::buy(1.0),
688 SignalType::Sell => Decision::sell(1.0),
689 SignalType::Close => Decision::close(),
690 })
691 }
692 async fn health(&self) -> BrainHealth {
693 BrainHealth::ok()
694 }
695 }
696
697 fn flat_series(n: usize, price: f64) -> Vec<Candle> {
698 (0..n)
699 .map(|i| Candle {
700 time: i as i64 * 60_000,
701 open: price,
702 high: price,
703 low: price,
704 close: price,
705 volume: 1.0,
706 })
707 .collect()
708 }
709
710 fn ramp_series(n: usize, start: f64, step: f64) -> Vec<Candle> {
711 (0..n)
712 .map(|i| {
713 let p = start + step * i as f64;
714 Candle {
715 time: i as i64 * 60_000,
716 open: p,
717 high: p,
718 low: p,
719 close: p,
720 volume: 1.0,
721 }
722 })
723 .collect()
724 }
725
726 fn cfg() -> BacktestConfig {
727 BacktestConfig::builder()
728 .symbol("BTCUSDT")
729 .initial_cash(10_000.0)
730 .sizing(SizingConfig {
731 margin_per_trade: 1_000.0,
732 leverage: 1,
733 max_contracts: 100,
734 })
735 .build()
736 .unwrap()
737 }
738
739 #[tokio::test]
740 async fn hold_brain_produces_no_trades() {
741 let result = Backtest::new(
742 cfg(),
743 Arc::new(FixedBrain {
744 signal: SignalType::Hold,
745 }),
746 )
747 .with_candles(flat_series(50, 100.0))
748 .run()
749 .await
750 .unwrap();
751 assert_eq!(result.signals_emitted, 0);
752 assert_eq!(result.orders_filled, 0);
753 assert_eq!(result.trades.len(), 0);
754 assert_eq!(result.net_pnl, 0.0);
755 assert_eq!(result.candles_processed, 50);
756 assert_eq!(result.equity_curve.len(), 51);
759 assert_eq!(result.period_returns.len(), 50);
760 }
761
762 #[tokio::test]
763 async fn buy_then_close_realises_pnl_on_uptrend() {
764 let result = Backtest::new(
768 cfg(),
769 Arc::new(FixedBrain {
770 signal: SignalType::Buy,
771 }),
772 )
773 .with_candles(ramp_series(20, 100.0, 1.0))
774 .run()
775 .await
776 .unwrap();
777 assert_eq!(result.orders_filled, 20);
780 assert_eq!(result.trades.len(), 0);
782 assert_eq!(result.net_pnl, 0.0);
783 }
784
785 #[tokio::test]
786 async fn determinism_two_runs_same_inputs() {
787 let series = ramp_series(30, 100.0, 0.5);
788 let r1 = Backtest::new(
789 cfg(),
790 Arc::new(FixedBrain {
791 signal: SignalType::Buy,
792 }),
793 )
794 .with_candles(series.clone())
795 .run()
796 .await
797 .unwrap();
798 let r2 = Backtest::new(
799 cfg(),
800 Arc::new(FixedBrain {
801 signal: SignalType::Buy,
802 }),
803 )
804 .with_candles(series)
805 .run()
806 .await
807 .unwrap();
808 assert_eq!(r1.candles_processed, r2.candles_processed);
809 assert_eq!(r1.signals_emitted, r2.signals_emitted);
810 assert_eq!(r1.orders_filled, r2.orders_filled);
811 assert_eq!(r1.trades.len(), r2.trades.len());
812 assert!((r1.net_pnl - r2.net_pnl).abs() < 1e-12);
813 assert_eq!(r1.equity_curve, r2.equity_curve);
814 }
815
816 #[tokio::test]
817 async fn close_against_flat_is_noop() {
818 let result = Backtest::new(
819 cfg(),
820 Arc::new(FixedBrain {
821 signal: SignalType::Close,
822 }),
823 )
824 .with_candles(flat_series(10, 100.0))
825 .run()
826 .await
827 .unwrap();
828 assert_eq!(result.orders_filled, 0);
829 assert_eq!(result.trades.len(), 0);
830 }
831
832 #[test]
833 fn merge_series_interleaves_by_timestamp() {
834 let s1 = Symbol::from("AAA");
835 let s2 = Symbol::from("BBB");
836 let series = vec![
837 (
838 s1.clone(),
839 vec![
840 Candle {
841 time: 1000,
842 open: 1.0,
843 high: 1.0,
844 low: 1.0,
845 close: 1.0,
846 volume: 0.0,
847 },
848 Candle {
849 time: 3000,
850 open: 1.0,
851 high: 1.0,
852 low: 1.0,
853 close: 1.0,
854 volume: 0.0,
855 },
856 ],
857 ),
858 (
859 s2.clone(),
860 vec![
861 Candle {
862 time: 2000,
863 open: 2.0,
864 high: 2.0,
865 low: 2.0,
866 close: 2.0,
867 volume: 0.0,
868 },
869 Candle {
870 time: 3000,
871 open: 2.0,
872 high: 2.0,
873 low: 2.0,
874 close: 2.0,
875 volume: 0.0,
876 },
877 ],
878 ),
879 ];
880 let merged = merge_series(&series);
881 let times: Vec<i64> = merged.iter().map(|(_, c)| c.time).collect();
882 assert_eq!(times, vec![1000, 2000, 3000, 3000]);
883 assert_eq!(merged[2].0, s1);
885 assert_eq!(merged[3].0, s2);
886 }
887
888 #[tokio::test]
889 async fn multi_symbol_routes_to_each_symbol_state() {
890 struct SymBrain;
893 #[async_trait]
894 impl Brain for SymBrain {
895 fn name(&self) -> &str {
896 "sym"
897 }
898 async fn on_event(&self, e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
899 match e.symbol().as_str() {
900 "AAA" => Ok(Decision::buy(1.0)),
901 "BBB" => Ok(Decision::sell(1.0)),
902 _ => Ok(Decision::hold()),
903 }
904 }
905 async fn health(&self) -> BrainHealth {
906 BrainHealth::ok()
907 }
908 }
909
910 let cfg = BacktestConfig::builder()
911 .symbols(["AAA", "BBB"])
912 .initial_cash(100_000.0)
913 .sizing(SizingConfig {
914 margin_per_trade: 1_000.0,
915 leverage: 1,
916 max_contracts: 100,
917 })
918 .build()
919 .unwrap();
920 let result = Backtest::new(cfg, Arc::new(SymBrain))
921 .with_symbol_candles("AAA", flat_series(5, 100.0))
922 .with_symbol_candles("BBB", flat_series(5, 200.0))
923 .run()
924 .await
925 .unwrap();
926 assert_eq!(result.candles_processed, 10);
928 assert_eq!(result.orders_filled, 10);
929 assert_eq!(result.trades.len(), 0);
931 assert_eq!(result.symbol, "AAA,BBB");
933 }
934
935 fn good_candle() -> Candle {
938 Candle {
939 time: 0,
940 open: 1.0,
941 high: 1.0,
942 low: 1.0,
943 close: 1.0,
944 volume: 1.0,
945 }
946 }
947
948 #[test]
949 fn validate_candle_accepts_finite_positive() {
950 assert!(validate_candle(&good_candle()).is_ok());
951 let c = Candle {
953 volume: 0.0,
954 ..good_candle()
955 };
956 assert!(validate_candle(&c).is_ok());
957 }
958
959 #[test]
960 fn validate_candle_rejects_non_finite_and_non_positive_prices() {
961 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
962 let c = Candle {
963 close: bad,
964 ..good_candle()
965 };
966 assert!(
967 validate_candle(&c).is_err(),
968 "close={bad} should be rejected"
969 );
970 }
971 }
972
973 #[test]
974 fn validate_candle_rejects_negative_or_nan_volume() {
975 for bad in [-1.0, f64::NAN, f64::INFINITY] {
976 let c = Candle {
977 volume: bad,
978 ..good_candle()
979 };
980 assert!(
981 validate_candle(&c).is_err(),
982 "volume={bad} should be rejected"
983 );
984 }
985 }
986
987 #[tokio::test]
988 async fn run_rejects_non_finite_candle() {
989 let mut series = flat_series(5, 100.0);
993 series[2].close = f64::NAN;
994 let err = Backtest::new(
995 cfg(),
996 Arc::new(FixedBrain {
997 signal: SignalType::Hold,
998 }),
999 )
1000 .with_candles(series)
1001 .run()
1002 .await
1003 .unwrap_err();
1004 assert!(matches!(err, Error::Data(_)), "got {err:?}");
1005 }
1006
1007 #[tokio::test]
1008 async fn multi_symbol_equity_curve_deterministic_across_runs() {
1009 struct DualLong;
1016 #[async_trait]
1017 impl Brain for DualLong {
1018 fn name(&self) -> &str {
1019 "dual-long"
1020 }
1021 async fn on_event(&self, e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
1022 if p.qty == 0.0 && matches!(e, MarketDataEvent::Candle { .. }) {
1023 Ok(Decision::buy(1.0))
1024 } else {
1025 Ok(Decision::hold())
1026 }
1027 }
1028 async fn health(&self) -> BrainHealth {
1029 BrainHealth::ok()
1030 }
1031 }
1032
1033 let run = || async {
1034 let cfg = BacktestConfig::builder()
1035 .symbols(["AAA", "BBB", "CCC"])
1036 .initial_cash(1_000_000.0)
1037 .sizing(SizingConfig {
1038 margin_per_trade: 1_000.0,
1039 leverage: 1,
1040 max_contracts: 100,
1041 })
1042 .build()
1043 .unwrap();
1044 Backtest::new(cfg, Arc::new(DualLong))
1045 .with_symbol_candles("AAA", ramp_series(40, 100.13, 0.37))
1046 .with_symbol_candles("BBB", ramp_series(40, 250.07, -0.19))
1047 .with_symbol_candles("CCC", ramp_series(40, 33.31, 0.53))
1048 .run()
1049 .await
1050 .unwrap()
1051 };
1052
1053 let r1 = run().await;
1054 let r2 = run().await;
1055 assert_eq!(r1.equity_curve, r2.equity_curve);
1057 assert_eq!(r1.period_returns, r2.period_returns);
1058 assert_eq!(r1.net_pnl.to_bits(), r2.net_pnl.to_bits());
1059 assert_eq!(r1.max_drawdown.to_bits(), r2.max_drawdown.to_bits());
1060 }
1061}