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::clock::ManualClock;
25use rustrade_risk::{CircuitBreaker, PositionSizer, SessionPnl};
26
27use crate::config::BacktestConfig;
28use crate::error::{Error, Result};
29use crate::metrics::TradeOutcome;
30use crate::result::BacktestResult;
31
32pub struct Backtest {
59 config: BacktestConfig,
60 brain: Arc<dyn Brain>,
61 series: Vec<(Symbol, Vec<Candle>)>,
65}
66
67impl Backtest {
68 pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
71 Self {
72 config,
73 brain,
74 series: Vec::new(),
75 }
76 }
77
78 pub fn with_candles(mut self, candles: Vec<Candle>) -> Self {
84 assert_eq!(
85 self.config.symbols.len(),
86 1,
87 "Backtest::with_candles requires a single-symbol config; \
88 this config has {} symbols. Use Backtest::with_symbol_candles instead.",
89 self.config.symbols.len()
90 );
91 let symbol = self.config.symbols[0].clone();
92 self.series = vec![(symbol, candles)];
93 self
94 }
95
96 pub fn with_symbol_candles(mut self, symbol: impl Into<Symbol>, candles: Vec<Candle>) -> Self {
103 let symbol = symbol.into();
104 self.series.retain(|(s, _)| s != &symbol);
105 self.series.push((symbol, candles));
106 self
107 }
108
109 pub async fn run(self) -> Result<BacktestResult> {
111 let exchange = Exchange::from("backtest");
112 let sizer = PositionSizer::new(self.config.sizing.clone());
113
114 let merged = merge_series(&self.series);
115 let candles_processed = merged.len();
116
117 for (symbol, candle) in &merged {
122 if let Err(why) = validate_candle(candle) {
123 return Err(Error::Data(format!(
124 "{symbol} candle at t={}: {why}",
125 candle.time
126 )));
127 }
128 }
129
130 let mut state = State::new(
131 self.config.initial_cash,
132 self.config.symbols.iter().cloned(),
133 );
134 let mut signals_emitted = 0usize;
135 let mut orders_filled = 0usize;
136 let mut orders_blocked = 0usize;
137 let mut trades: Vec<TradeOutcome> = Vec::new();
138
139 let risk_clock = Arc::new(ManualClock::new(0));
145 let mut risk: BTreeMap<Symbol, SymbolRisk> = BTreeMap::new();
146 if self.config.session_pnl.is_some() || self.config.circuit_breaker.is_some() {
147 for s in &self.config.symbols {
148 risk.insert(
149 s.clone(),
150 SymbolRisk {
151 session: self
152 .config
153 .session_pnl
154 .clone()
155 .map(|c| SessionPnl::with_clock(s.as_str(), c, risk_clock.clone())),
156 breaker: self
157 .config
158 .circuit_breaker
159 .clone()
160 .map(|c| CircuitBreaker::with_clock(c, risk_clock.clone())),
161 },
162 );
163 }
164 }
165
166 for (symbol, candle) in &merged {
167 if !risk.is_empty() {
171 risk_clock.set(candle.time.max(0) as u64 / 1_000);
172 if let Some(r) = risk.get_mut(symbol) {
173 r.tick();
174 }
175 }
176 let event = MarketDataEvent::Candle {
177 exchange: exchange.clone(),
178 symbol: symbol.clone(),
179 candle: *candle,
180 };
181
182 let position = state.position(symbol).copied().unwrap_or(Position::FLAT);
188 let decision = self
189 .brain
190 .on_event(&event, &position)
191 .await
192 .map_err(|e| Error::Brain(e.to_string()))?;
193
194 let in_config = state.has_symbol(symbol);
195
196 if !in_config || matches!(decision.signal, SignalType::Hold) {
197 state.sample_step(symbol, candle.close, self.config.contract_value);
198 continue;
199 }
200 signals_emitted += 1;
201
202 if let Some(r) = risk.get(symbol)
207 && (r
208 .session
209 .as_ref()
210 .is_some_and(SessionPnl::is_session_halted)
211 || r.breaker.as_ref().is_some_and(CircuitBreaker::is_tripped))
212 {
213 orders_blocked += 1;
214 state.sample_step(symbol, candle.close, self.config.contract_value);
215 continue;
216 }
217
218 let Some(resolved) = resolve_order(
223 &decision,
224 &position,
225 &sizer,
226 candle.close,
227 self.config.contract_value,
228 ) else {
229 state.sample_step(symbol, candle.close, self.config.contract_value);
230 continue;
231 };
232 if resolved.qty <= 0.0 {
233 state.sample_step(symbol, candle.close, self.config.contract_value);
234 continue;
235 }
236
237 let Some((reference_price, is_taker)) = resolve_fill(&resolved, candle) else {
245 state.sample_step(symbol, candle.close, self.config.contract_value);
246 continue;
247 };
248 let fill_price = if is_taker {
251 self.config.slippage.apply(resolved.side, reference_price)
252 } else {
253 reference_price
254 };
255 let fee = self.config.fees.fee_for(
256 fill_price,
257 resolved.qty * self.config.contract_value,
258 is_taker,
259 );
260
261 let trades_before = trades.len();
264 apply_fill(
265 &mut state,
266 symbol,
267 resolved.side,
268 resolved.qty,
269 fill_price,
270 fee,
271 self.config.contract_value,
272 candle_time(candle),
273 &mut trades,
274 );
275
276 if let Some(r) = risk.get_mut(symbol) {
279 for t in &trades[trades_before..] {
280 if let Some(session) = &mut r.session {
281 session.record_close(t.gross_pnl, t.fee);
282 }
283 if let Some(breaker) = &mut r.breaker {
284 let net = t.net_pnl();
285 if net > 0.0 {
286 breaker.record_win();
287 } else if net < 0.0 {
288 breaker.record_loss();
289 }
290 }
291 }
292 }
293
294 orders_filled += 1;
295
296 let fill = Fill {
299 symbol: symbol.clone(),
300 order_id: format!("bt-{orders_filled}"),
301 client_id: None,
302 side: resolved.side,
303 price: rustrade_core::Price(fill_price),
304 size: rustrade_core::Volume(resolved.qty),
305 fee,
306 fee_currency: "QUOTE".into(),
307 timestamp: candle_time(candle),
308 };
309 self.brain
310 .on_fill(&fill)
311 .await
312 .map_err(|e| Error::Brain(e.to_string()))?;
313
314 state.sample_step(symbol, candle.close, self.config.contract_value);
315 }
316
317 let total_fees: f64 = trades.iter().map(|t| t.fee).sum();
318 let net_pnl: f64 = trades.iter().map(|t| t.net_pnl()).sum();
319 let symbol_label = if self.config.symbols.len() == 1 {
320 self.config.symbols[0].as_str().to_string()
321 } else {
322 let parts: Vec<&str> = self.config.symbols.iter().map(|s| s.as_str()).collect();
324 parts.join(",")
325 };
326
327 let returns = state.into_returns();
328 Ok(BacktestResult {
329 symbol: symbol_label,
330 initial_cash: self.config.initial_cash,
331 final_cash: self.config.initial_cash + net_pnl,
332 net_pnl,
333 total_fees,
334 candles_processed,
335 signals_emitted,
336 orders_filled,
337 orders_blocked,
338 trades,
339 max_drawdown: returns.max_drawdown,
340 equity_curve: returns.equity,
341 period_returns: returns.period_returns,
342 risk_free_rate: self.config.risk_free_rate,
343 periods_per_year: self.config.periods_per_year,
344 })
345 }
346}
347
348struct SymbolRisk {
351 session: Option<SessionPnl>,
352 breaker: Option<CircuitBreaker>,
353}
354
355impl SymbolRisk {
356 fn tick(&mut self) {
358 if let Some(s) = &mut self.session {
359 s.tick();
360 }
361 if let Some(b) = &mut self.breaker {
362 b.tick();
363 }
364 }
365}
366
367fn merge_series(series: &[(Symbol, Vec<Candle>)]) -> Vec<(Symbol, Candle)> {
375 let total: usize = series.iter().map(|(_, c)| c.len()).sum();
376 let mut out: Vec<(Symbol, Candle, usize)> = Vec::with_capacity(total);
377 for (series_idx, (sym, candles)) in series.iter().enumerate() {
378 for c in candles {
379 out.push((sym.clone(), *c, series_idx));
380 }
381 }
382 out.sort_by(|a, b| a.1.time.cmp(&b.1.time).then(a.2.cmp(&b.2)));
385 out.into_iter().map(|(s, c, _)| (s, c)).collect()
386}
387
388struct State {
394 positions: BTreeMap<Symbol, Position>,
402 cash: f64,
403 equity_hwm: f64,
404 max_drawdown: f64,
405 last_equity: f64,
408 equity_curve: Vec<f64>,
409 period_returns: Vec<f64>,
410 last_marks: BTreeMap<Symbol, f64>,
415}
416
417struct ReturnsSummary {
418 max_drawdown: f64,
419 equity: Vec<f64>,
420 period_returns: Vec<f64>,
421}
422
423impl State {
424 fn new(initial_cash: f64, symbols: impl IntoIterator<Item = Symbol>) -> Self {
425 let mut positions = BTreeMap::new();
426 for s in symbols {
427 positions.insert(s, Position::FLAT);
428 }
429 Self {
430 positions,
431 cash: initial_cash,
432 equity_hwm: initial_cash,
433 max_drawdown: 0.0,
434 last_equity: initial_cash,
435 equity_curve: vec![initial_cash],
436 period_returns: Vec::new(),
437 last_marks: BTreeMap::new(),
438 }
439 }
440
441 fn has_symbol(&self, sym: &Symbol) -> bool {
442 self.positions.contains_key(sym)
443 }
444
445 fn position(&self, sym: &Symbol) -> Option<&Position> {
446 self.positions.get(sym)
447 }
448
449 fn position_mut(&mut self, sym: &Symbol) -> &mut Position {
450 self.positions.entry(sym.clone()).or_insert(Position::FLAT)
451 }
452
453 fn sample_step(&mut self, sym: &Symbol, close: f64, contract_value: f64) {
457 self.last_marks.insert(sym.clone(), close);
458 let equity = self.equity_now(contract_value);
459
460 if equity > self.equity_hwm {
462 self.equity_hwm = equity;
463 }
464 let dd = equity - self.equity_hwm;
465 if dd < self.max_drawdown {
466 self.max_drawdown = dd;
467 }
468
469 self.equity_curve.push(equity);
470 let prev = self.last_equity;
474 if prev > 0.0 {
475 self.period_returns.push((equity - prev) / prev);
476 } else {
477 self.period_returns.push(0.0);
478 }
479 self.last_equity = equity;
480 }
481
482 fn equity_now(&self, contract_value: f64) -> f64 {
485 let mut equity = self.cash;
486 for (sym, pos) in &self.positions {
487 if let Some(entry) = pos.entry_price
488 && let Some(mark) = self.last_marks.get(sym)
489 {
490 let pnl_per_unit = (mark - entry) * pos.qty.signum();
491 equity += pnl_per_unit * pos.qty.abs() * contract_value;
492 }
493 }
494 equity
495 }
496
497 fn into_returns(self) -> ReturnsSummary {
498 ReturnsSummary {
499 max_drawdown: self.max_drawdown,
500 equity: self.equity_curve,
501 period_returns: self.period_returns,
502 }
503 }
504}
505
506pub(crate) fn validate_candle(c: &Candle) -> std::result::Result<(), String> {
514 for (name, v) in [
515 ("open", c.open),
516 ("high", c.high),
517 ("low", c.low),
518 ("close", c.close),
519 ] {
520 if !v.is_finite() || v <= 0.0 {
521 return Err(format!("{name}={v} (prices must be finite and > 0)"));
522 }
523 }
524 if !c.volume.is_finite() || c.volume < 0.0 {
525 return Err(format!("volume={} (must be finite and >= 0)", c.volume));
526 }
527 Ok(())
528}
529
530struct ResolvedOrder {
532 side: Side,
533 qty: f64,
534 is_close: bool,
535 kind: OrderKind,
536 limit_price: Option<f64>,
539}
540
541fn resolve_order(
543 decision: &Decision,
544 position: &Position,
545 sizer: &PositionSizer,
546 price: f64,
547 contract_value: f64,
548) -> Option<ResolvedOrder> {
549 match decision.signal {
550 SignalType::Hold => None,
551 SignalType::Close => {
552 let close_side = position.close_side()?;
553 Some(ResolvedOrder {
554 side: close_side,
555 qty: position.qty.abs(),
556 is_close: true,
557 kind: OrderKind::Market,
558 limit_price: None,
559 })
560 }
561 SignalType::Buy | SignalType::Sell => {
562 let side = if matches!(decision.signal, SignalType::Buy) {
563 Side::Buy
564 } else {
565 Side::Sell
566 };
567 let contracts = size_from_hint(sizer, decision.size_hint, price, contract_value);
568 if contracts == 0 {
569 None
570 } else {
571 Some(ResolvedOrder {
572 side,
573 qty: contracts as f64,
574 is_close: false,
575 kind: decision.order_kind,
576 limit_price: decision.limit_price.map(|p| p.value()),
577 })
578 }
579 }
580 }
581}
582
583fn resolve_fill(resolved: &ResolvedOrder, candle: &Candle) -> Option<(f64, bool)> {
597 if resolved.is_close
598 || matches!(
599 resolved.kind,
600 OrderKind::Market | OrderKind::Ioc | OrderKind::Fok
601 )
602 {
603 return Some((candle.close, true));
604 }
605
606 let limit = resolved.limit_price.unwrap_or(candle.close);
607 let (fills, price, marketable) = match resolved.side {
608 Side::Buy => (
609 candle.low <= limit,
610 limit.min(candle.open),
611 limit >= candle.open,
612 ),
613 Side::Sell => (
614 candle.high >= limit,
615 limit.max(candle.open),
616 limit <= candle.open,
617 ),
618 };
619 if !fills {
620 return None;
621 }
622 if matches!(resolved.kind, OrderKind::PostOnly) && marketable {
623 return None;
624 }
625 Some((price, marketable))
626}
627
628fn size_from_hint(sizer: &PositionSizer, hint: SizeHint, price: f64, contract_value: f64) -> u32 {
629 match hint {
630 SizeHint::Default => sizer.contracts(price, contract_value),
631 SizeHint::MarginFraction(f) => {
632 let f = f.clamp(0.0, 1.0);
633 let margin = sizer.config().margin_per_trade * f;
634 sizer.contracts_with_margin(margin, price, contract_value)
635 }
636 SizeHint::NotionalUsd(n) => {
637 let leverage = sizer.config().leverage.max(1);
638 let margin = n / f64::from(leverage);
639 sizer.contracts_with_margin(margin, price, contract_value)
640 }
641 SizeHint::Quantity(q) => {
642 let raw = q.value().max(0.0).floor() as u32;
643 raw.min(sizer.config().max_contracts)
644 }
645 }
646}
647
648#[allow(clippy::too_many_arguments)]
651fn apply_fill(
652 state: &mut State,
653 symbol: &Symbol,
654 side: Side,
655 qty: f64,
656 fill_price: f64,
657 fee: f64,
658 contract_value: f64,
659 when: DateTime<Utc>,
660 trades: &mut Vec<TradeOutcome>,
661) {
662 let signed_qty = match side {
664 Side::Buy => qty,
665 Side::Sell => -qty,
666 };
667
668 let (old_qty, old_entry) = {
669 let p = state.position_mut(symbol);
670 (p.qty, p.entry_price)
671 };
672 let new_qty = old_qty + signed_qty;
673
674 let closing_qty = if old_qty.signum() != signed_qty.signum() && old_qty != 0.0 {
678 old_qty.abs().min(qty)
679 } else {
680 0.0
681 };
682 let opening_qty = qty - closing_qty;
683
684 if closing_qty > 0.0 {
685 let entry = old_entry.unwrap_or(fill_price);
686 let direction = old_qty.signum();
687 let gross = (fill_price - entry) * direction * closing_qty * contract_value;
688 let fee_share = if qty > 0.0 {
691 fee * (closing_qty / qty)
692 } else {
693 0.0
694 };
695 trades.push(TradeOutcome {
696 symbol: symbol.as_str().to_string(),
697 close_side: side,
698 qty: closing_qty,
699 entry_price: entry,
700 exit_price: fill_price,
701 gross_pnl: gross,
702 fee: fee_share,
703 closed_at: when,
704 });
705 state.cash += gross - fee_share;
706 }
707
708 let new_position = if opening_qty > 0.0 {
709 let fee_open = if qty > 0.0 {
711 fee * (opening_qty / qty)
712 } else {
713 0.0
714 };
715 state.cash -= fee_open;
716 let new_position_qty_after_close = old_qty + side_sign(side) * closing_qty;
722 let post_open_qty = new_position_qty_after_close + side_sign(side) * opening_qty;
723 let entry = if new_position_qty_after_close == 0.0 {
724 fill_price
725 } else {
726 let prev_entry = old_entry.unwrap_or(fill_price);
727 let prev_notional = prev_entry * new_position_qty_after_close.abs();
728 let new_notional = fill_price * opening_qty;
729 (prev_notional + new_notional) / post_open_qty.abs()
730 };
731 Position {
732 qty: post_open_qty,
733 entry_price: Some(entry),
734 unrealised_pnl: 0.0,
735 }
736 } else if new_qty == 0.0 {
737 Position::FLAT
738 } else {
739 Position {
740 qty: new_qty,
741 entry_price: old_entry,
742 unrealised_pnl: 0.0,
743 }
744 };
745 *state.position_mut(symbol) = new_position;
746}
747
748fn side_sign(side: Side) -> f64 {
749 match side {
750 Side::Buy => 1.0,
751 Side::Sell => -1.0,
752 }
753}
754
755fn candle_time(c: &Candle) -> DateTime<Utc> {
756 Utc.timestamp_millis_opt(c.time)
757 .single()
758 .unwrap_or_else(Utc::now)
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use async_trait::async_trait;
765 use rustrade_core::{BrainHealth, Decision, MarketDataEvent, Position, Result as CoreResult};
766 use rustrade_risk::SizingConfig;
767
768 struct FixedBrain {
770 signal: SignalType,
771 }
772 #[async_trait]
773 impl Brain for FixedBrain {
774 fn name(&self) -> &str {
775 "fixed"
776 }
777 async fn on_event(&self, _e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
778 Ok(match self.signal {
779 SignalType::Hold => Decision::hold(),
780 SignalType::Buy => Decision::buy(1.0),
781 SignalType::Sell => Decision::sell(1.0),
782 SignalType::Close => Decision::close(),
783 })
784 }
785 async fn health(&self) -> BrainHealth {
786 BrainHealth::ok()
787 }
788 }
789
790 fn flat_series(n: usize, price: f64) -> Vec<Candle> {
791 (0..n)
792 .map(|i| Candle {
793 time: i as i64 * 60_000,
794 open: price,
795 high: price,
796 low: price,
797 close: price,
798 volume: 1.0,
799 })
800 .collect()
801 }
802
803 fn ramp_series(n: usize, start: f64, step: f64) -> Vec<Candle> {
804 (0..n)
805 .map(|i| {
806 let p = start + step * i as f64;
807 Candle {
808 time: i as i64 * 60_000,
809 open: p,
810 high: p,
811 low: p,
812 close: p,
813 volume: 1.0,
814 }
815 })
816 .collect()
817 }
818
819 fn cfg() -> BacktestConfig {
820 BacktestConfig::builder()
821 .symbol("BTCUSDT")
822 .initial_cash(10_000.0)
823 .sizing(SizingConfig {
824 margin_per_trade: 1_000.0,
825 leverage: 1,
826 max_contracts: 100,
827 })
828 .build()
829 .unwrap()
830 }
831
832 #[tokio::test]
833 async fn hold_brain_produces_no_trades() {
834 let result = Backtest::new(
835 cfg(),
836 Arc::new(FixedBrain {
837 signal: SignalType::Hold,
838 }),
839 )
840 .with_candles(flat_series(50, 100.0))
841 .run()
842 .await
843 .unwrap();
844 assert_eq!(result.signals_emitted, 0);
845 assert_eq!(result.orders_filled, 0);
846 assert_eq!(result.trades.len(), 0);
847 assert_eq!(result.net_pnl, 0.0);
848 assert_eq!(result.candles_processed, 50);
849 assert_eq!(result.equity_curve.len(), 51);
852 assert_eq!(result.period_returns.len(), 50);
853 }
854
855 #[tokio::test]
856 async fn buy_then_close_realises_pnl_on_uptrend() {
857 let result = Backtest::new(
861 cfg(),
862 Arc::new(FixedBrain {
863 signal: SignalType::Buy,
864 }),
865 )
866 .with_candles(ramp_series(20, 100.0, 1.0))
867 .run()
868 .await
869 .unwrap();
870 assert_eq!(result.orders_filled, 20);
873 assert_eq!(result.trades.len(), 0);
875 assert_eq!(result.net_pnl, 0.0);
876 }
877
878 #[tokio::test]
879 async fn determinism_two_runs_same_inputs() {
880 let series = ramp_series(30, 100.0, 0.5);
881 let r1 = Backtest::new(
882 cfg(),
883 Arc::new(FixedBrain {
884 signal: SignalType::Buy,
885 }),
886 )
887 .with_candles(series.clone())
888 .run()
889 .await
890 .unwrap();
891 let r2 = Backtest::new(
892 cfg(),
893 Arc::new(FixedBrain {
894 signal: SignalType::Buy,
895 }),
896 )
897 .with_candles(series)
898 .run()
899 .await
900 .unwrap();
901 assert_eq!(r1.candles_processed, r2.candles_processed);
902 assert_eq!(r1.signals_emitted, r2.signals_emitted);
903 assert_eq!(r1.orders_filled, r2.orders_filled);
904 assert_eq!(r1.trades.len(), r2.trades.len());
905 assert!((r1.net_pnl - r2.net_pnl).abs() < 1e-12);
906 assert_eq!(r1.equity_curve, r2.equity_curve);
907 }
908
909 #[tokio::test]
910 async fn close_against_flat_is_noop() {
911 let result = Backtest::new(
912 cfg(),
913 Arc::new(FixedBrain {
914 signal: SignalType::Close,
915 }),
916 )
917 .with_candles(flat_series(10, 100.0))
918 .run()
919 .await
920 .unwrap();
921 assert_eq!(result.orders_filled, 0);
922 assert_eq!(result.trades.len(), 0);
923 }
924
925 #[test]
926 fn merge_series_interleaves_by_timestamp() {
927 let s1 = Symbol::from("AAA");
928 let s2 = Symbol::from("BBB");
929 let series = vec![
930 (
931 s1.clone(),
932 vec![
933 Candle {
934 time: 1000,
935 open: 1.0,
936 high: 1.0,
937 low: 1.0,
938 close: 1.0,
939 volume: 0.0,
940 },
941 Candle {
942 time: 3000,
943 open: 1.0,
944 high: 1.0,
945 low: 1.0,
946 close: 1.0,
947 volume: 0.0,
948 },
949 ],
950 ),
951 (
952 s2.clone(),
953 vec![
954 Candle {
955 time: 2000,
956 open: 2.0,
957 high: 2.0,
958 low: 2.0,
959 close: 2.0,
960 volume: 0.0,
961 },
962 Candle {
963 time: 3000,
964 open: 2.0,
965 high: 2.0,
966 low: 2.0,
967 close: 2.0,
968 volume: 0.0,
969 },
970 ],
971 ),
972 ];
973 let merged = merge_series(&series);
974 let times: Vec<i64> = merged.iter().map(|(_, c)| c.time).collect();
975 assert_eq!(times, vec![1000, 2000, 3000, 3000]);
976 assert_eq!(merged[2].0, s1);
978 assert_eq!(merged[3].0, s2);
979 }
980
981 #[tokio::test]
982 async fn multi_symbol_routes_to_each_symbol_state() {
983 struct SymBrain;
986 #[async_trait]
987 impl Brain for SymBrain {
988 fn name(&self) -> &str {
989 "sym"
990 }
991 async fn on_event(&self, e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
992 match e.symbol().as_str() {
993 "AAA" => Ok(Decision::buy(1.0)),
994 "BBB" => Ok(Decision::sell(1.0)),
995 _ => Ok(Decision::hold()),
996 }
997 }
998 async fn health(&self) -> BrainHealth {
999 BrainHealth::ok()
1000 }
1001 }
1002
1003 let cfg = BacktestConfig::builder()
1004 .symbols(["AAA", "BBB"])
1005 .initial_cash(100_000.0)
1006 .sizing(SizingConfig {
1007 margin_per_trade: 1_000.0,
1008 leverage: 1,
1009 max_contracts: 100,
1010 })
1011 .build()
1012 .unwrap();
1013 let result = Backtest::new(cfg, Arc::new(SymBrain))
1014 .with_symbol_candles("AAA", flat_series(5, 100.0))
1015 .with_symbol_candles("BBB", flat_series(5, 200.0))
1016 .run()
1017 .await
1018 .unwrap();
1019 assert_eq!(result.candles_processed, 10);
1021 assert_eq!(result.orders_filled, 10);
1022 assert_eq!(result.trades.len(), 0);
1024 assert_eq!(result.symbol, "AAA,BBB");
1026 }
1027
1028 fn good_candle() -> Candle {
1031 Candle {
1032 time: 0,
1033 open: 1.0,
1034 high: 1.0,
1035 low: 1.0,
1036 close: 1.0,
1037 volume: 1.0,
1038 }
1039 }
1040
1041 #[test]
1042 fn validate_candle_accepts_finite_positive() {
1043 assert!(validate_candle(&good_candle()).is_ok());
1044 let c = Candle {
1046 volume: 0.0,
1047 ..good_candle()
1048 };
1049 assert!(validate_candle(&c).is_ok());
1050 }
1051
1052 #[test]
1053 fn validate_candle_rejects_non_finite_and_non_positive_prices() {
1054 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1055 let c = Candle {
1056 close: bad,
1057 ..good_candle()
1058 };
1059 assert!(
1060 validate_candle(&c).is_err(),
1061 "close={bad} should be rejected"
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn validate_candle_rejects_negative_or_nan_volume() {
1068 for bad in [-1.0, f64::NAN, f64::INFINITY] {
1069 let c = Candle {
1070 volume: bad,
1071 ..good_candle()
1072 };
1073 assert!(
1074 validate_candle(&c).is_err(),
1075 "volume={bad} should be rejected"
1076 );
1077 }
1078 }
1079
1080 #[tokio::test]
1081 async fn run_rejects_non_finite_candle() {
1082 let mut series = flat_series(5, 100.0);
1086 series[2].close = f64::NAN;
1087 let err = Backtest::new(
1088 cfg(),
1089 Arc::new(FixedBrain {
1090 signal: SignalType::Hold,
1091 }),
1092 )
1093 .with_candles(series)
1094 .run()
1095 .await
1096 .unwrap_err();
1097 assert!(matches!(err, Error::Data(_)), "got {err:?}");
1098 }
1099
1100 #[tokio::test]
1101 async fn multi_symbol_equity_curve_deterministic_across_runs() {
1102 struct DualLong;
1109 #[async_trait]
1110 impl Brain for DualLong {
1111 fn name(&self) -> &str {
1112 "dual-long"
1113 }
1114 async fn on_event(&self, e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
1115 if p.qty == 0.0 && matches!(e, MarketDataEvent::Candle { .. }) {
1116 Ok(Decision::buy(1.0))
1117 } else {
1118 Ok(Decision::hold())
1119 }
1120 }
1121 async fn health(&self) -> BrainHealth {
1122 BrainHealth::ok()
1123 }
1124 }
1125
1126 let run = || async {
1127 let cfg = BacktestConfig::builder()
1128 .symbols(["AAA", "BBB", "CCC"])
1129 .initial_cash(1_000_000.0)
1130 .sizing(SizingConfig {
1131 margin_per_trade: 1_000.0,
1132 leverage: 1,
1133 max_contracts: 100,
1134 })
1135 .build()
1136 .unwrap();
1137 Backtest::new(cfg, Arc::new(DualLong))
1138 .with_symbol_candles("AAA", ramp_series(40, 100.13, 0.37))
1139 .with_symbol_candles("BBB", ramp_series(40, 250.07, -0.19))
1140 .with_symbol_candles("CCC", ramp_series(40, 33.31, 0.53))
1141 .run()
1142 .await
1143 .unwrap()
1144 };
1145
1146 let r1 = run().await;
1147 let r2 = run().await;
1148 assert_eq!(r1.equity_curve, r2.equity_curve);
1150 assert_eq!(r1.period_returns, r2.period_returns);
1151 assert_eq!(r1.net_pnl.to_bits(), r2.net_pnl.to_bits());
1152 assert_eq!(r1.max_drawdown.to_bits(), r2.max_drawdown.to_bits());
1153 }
1154}