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::fees::FeeModel;
30use crate::metrics::TradeOutcome;
31use crate::result::BacktestResult;
32use crate::slippage::SlippageModel;
33
34pub struct Backtest {
61 config: BacktestConfig,
62 brain: Arc<dyn Brain>,
63 series: Vec<(Symbol, Vec<Candle>)>,
67}
68
69impl Backtest {
70 pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
73 Self {
74 config,
75 brain,
76 series: Vec::new(),
77 }
78 }
79
80 pub fn with_candles(mut self, candles: Vec<Candle>) -> Self {
86 assert_eq!(
87 self.config.symbols.len(),
88 1,
89 "Backtest::with_candles requires a single-symbol config; \
90 this config has {} symbols. Use Backtest::with_symbol_candles instead.",
91 self.config.symbols.len()
92 );
93 let symbol = self.config.symbols[0].clone();
94 self.series = vec![(symbol, candles)];
95 self
96 }
97
98 pub fn with_symbol_candles(mut self, symbol: impl Into<Symbol>, candles: Vec<Candle>) -> Self {
105 let symbol = symbol.into();
106 self.series.retain(|(s, _)| s != &symbol);
107 self.series.push((symbol, candles));
108 self
109 }
110
111 pub async fn run(self) -> Result<BacktestResult> {
113 let exchange = Exchange::from("backtest");
114 let sizer = PositionSizer::new(self.config.sizing.clone());
115
116 let merged = merge_series(&self.series);
117 let candles_processed = merged.len();
118
119 for (symbol, candle) in &merged {
124 if let Err(why) = validate_candle(candle) {
125 return Err(Error::Data(format!(
126 "{symbol} candle at t={}: {why}",
127 candle.time
128 )));
129 }
130 }
131
132 let mut state = State::new(
133 self.config.initial_cash,
134 self.config.symbols.iter().cloned(),
135 );
136 let mut signals_emitted = 0usize;
137 let mut orders_filled = 0usize;
138 let mut orders_blocked = 0usize;
139 let mut trades: Vec<TradeOutcome> = Vec::new();
140
141 let risk_clock = Arc::new(ManualClock::new(0));
147 let mut risk: BTreeMap<Symbol, SymbolRisk> = BTreeMap::new();
148 if self.config.session_pnl.is_some() || self.config.circuit_breaker.is_some() {
149 for s in &self.config.symbols {
150 risk.insert(
151 s.clone(),
152 SymbolRisk {
153 session: self
154 .config
155 .session_pnl
156 .clone()
157 .map(|c| SessionPnl::with_clock(s.as_str(), c, risk_clock.clone())),
158 breaker: self
159 .config
160 .circuit_breaker
161 .clone()
162 .map(|c| CircuitBreaker::with_clock(c, risk_clock.clone())),
163 },
164 );
165 }
166 }
167
168 for (symbol, candle) in &merged {
169 if !risk.is_empty() {
173 risk_clock.set(candle.time.max(0) as u64 / 1_000);
174 if let Some(r) = risk.get_mut(symbol) {
175 r.tick();
176 }
177 }
178
179 {
185 let before = trades.len();
186 if let Some(fill) = state.check_bracket(
187 symbol,
188 candle,
189 self.config.fees,
190 self.config.slippage,
191 self.config.contract_value,
192 candle_time(candle),
193 &mut trades,
194 ) {
195 if let Some(r) = risk.get_mut(symbol) {
196 record_closes_into_risk(r, &trades[before..]);
197 }
198 orders_filled += 1;
199 self.brain
200 .on_fill(&fill)
201 .await
202 .map_err(|e| Error::Brain(e.to_string()))?;
203 }
204 }
205
206 let event = MarketDataEvent::Candle {
207 exchange: exchange.clone(),
208 symbol: symbol.clone(),
209 candle: *candle,
210 };
211
212 let position = state.position(symbol).copied().unwrap_or(Position::FLAT);
218 let decision = self
219 .brain
220 .on_event(&event, &position)
221 .await
222 .map_err(|e| Error::Brain(e.to_string()))?;
223
224 let in_config = state.has_symbol(symbol);
225
226 if !in_config || matches!(decision.signal, SignalType::Hold) {
227 state.sample_step(symbol, candle.close, self.config.contract_value);
228 continue;
229 }
230 signals_emitted += 1;
231
232 if let Some(r) = risk.get(symbol)
237 && (r
238 .session
239 .as_ref()
240 .is_some_and(SessionPnl::is_session_halted)
241 || r.breaker.as_ref().is_some_and(CircuitBreaker::is_tripped))
242 {
243 orders_blocked += 1;
244 state.sample_step(symbol, candle.close, self.config.contract_value);
245 continue;
246 }
247
248 let Some(resolved) = resolve_order(
253 &decision,
254 &position,
255 &sizer,
256 candle.close,
257 self.config.contract_value,
258 ) else {
259 state.sample_step(symbol, candle.close, self.config.contract_value);
260 continue;
261 };
262 if resolved.qty <= 0.0 {
263 state.sample_step(symbol, candle.close, self.config.contract_value);
264 continue;
265 }
266
267 let Some((reference_price, is_taker)) = resolve_fill(&resolved, candle) else {
275 state.sample_step(symbol, candle.close, self.config.contract_value);
276 continue;
277 };
278 let fill_price = if is_taker {
281 self.config.slippage.apply(resolved.side, reference_price)
282 } else {
283 reference_price
284 };
285 let fee = self.config.fees.fee_for(
286 fill_price,
287 resolved.qty * self.config.contract_value,
288 is_taker,
289 );
290
291 let trades_before = trades.len();
294 apply_fill(
295 &mut state,
296 symbol,
297 resolved.side,
298 resolved.qty,
299 fill_price,
300 fee,
301 self.config.contract_value,
302 candle_time(candle),
303 &mut trades,
304 );
305
306 if let Some(r) = risk.get_mut(symbol) {
309 record_closes_into_risk(r, &trades[trades_before..]);
310 }
311
312 orders_filled += 1;
313
314 let fill = Fill {
317 symbol: symbol.clone(),
318 order_id: format!("bt-{orders_filled}"),
319 client_id: None,
320 side: resolved.side,
321 price: rustrade_core::Price(fill_price),
322 size: rustrade_core::Volume(resolved.qty),
323 fee,
324 fee_currency: "QUOTE".into(),
325 timestamp: candle_time(candle),
326 };
327 self.brain
328 .on_fill(&fill)
329 .await
330 .map_err(|e| Error::Brain(e.to_string()))?;
331
332 if !resolved.is_close
336 && let (Some(sl), Some(tp)) = (decision.stop_price, decision.take_profit_price)
337 {
338 let pos = state.position(symbol).copied().unwrap_or(Position::FLAT);
339 if pos.qty != 0.0 {
340 state.set_bracket(
341 symbol,
342 Bracket { stop: sl.value(), take_profit: tp.value(), long: pos.qty > 0.0 },
343 );
344 }
345 }
346
347 state.sample_step(symbol, candle.close, self.config.contract_value);
348 }
349
350 let total_fees: f64 = trades.iter().map(|t| t.fee).sum();
351 let net_pnl: f64 = trades.iter().map(|t| t.net_pnl()).sum();
352 let symbol_label = if self.config.symbols.len() == 1 {
353 self.config.symbols[0].as_str().to_string()
354 } else {
355 let parts: Vec<&str> = self.config.symbols.iter().map(|s| s.as_str()).collect();
357 parts.join(",")
358 };
359
360 let returns = state.into_returns();
361 Ok(BacktestResult {
362 symbol: symbol_label,
363 initial_cash: self.config.initial_cash,
364 final_cash: self.config.initial_cash + net_pnl,
365 net_pnl,
366 total_fees,
367 candles_processed,
368 signals_emitted,
369 orders_filled,
370 orders_blocked,
371 trades,
372 max_drawdown: returns.max_drawdown,
373 equity_curve: returns.equity,
374 period_returns: returns.period_returns,
375 risk_free_rate: self.config.risk_free_rate,
376 periods_per_year: self.config.periods_per_year,
377 })
378 }
379}
380
381struct SymbolRisk {
384 session: Option<SessionPnl>,
385 breaker: Option<CircuitBreaker>,
386}
387
388impl SymbolRisk {
389 fn tick(&mut self) {
391 if let Some(s) = &mut self.session {
392 s.tick();
393 }
394 if let Some(b) = &mut self.breaker {
395 b.tick();
396 }
397 }
398}
399
400fn merge_series(series: &[(Symbol, Vec<Candle>)]) -> Vec<(Symbol, Candle)> {
408 let total: usize = series.iter().map(|(_, c)| c.len()).sum();
409 let mut out: Vec<(Symbol, Candle, usize)> = Vec::with_capacity(total);
410 for (series_idx, (sym, candles)) in series.iter().enumerate() {
411 for c in candles {
412 out.push((sym.clone(), *c, series_idx));
413 }
414 }
415 out.sort_by(|a, b| a.1.time.cmp(&b.1.time).then(a.2.cmp(&b.2)));
418 out.into_iter().map(|(s, c, _)| (s, c)).collect()
419}
420
421struct State {
427 positions: BTreeMap<Symbol, Position>,
435 cash: f64,
436 equity_hwm: f64,
437 max_drawdown: f64,
438 last_equity: f64,
441 equity_curve: Vec<f64>,
442 period_returns: Vec<f64>,
443 last_marks: BTreeMap<Symbol, f64>,
448 brackets: BTreeMap<Symbol, Bracket>,
453}
454
455struct ReturnsSummary {
456 max_drawdown: f64,
457 equity: Vec<f64>,
458 period_returns: Vec<f64>,
459}
460
461impl State {
462 fn new(initial_cash: f64, symbols: impl IntoIterator<Item = Symbol>) -> Self {
463 let mut positions = BTreeMap::new();
464 for s in symbols {
465 positions.insert(s, Position::FLAT);
466 }
467 Self {
468 positions,
469 cash: initial_cash,
470 equity_hwm: initial_cash,
471 max_drawdown: 0.0,
472 last_equity: initial_cash,
473 equity_curve: vec![initial_cash],
474 period_returns: Vec::new(),
475 last_marks: BTreeMap::new(),
476 brackets: BTreeMap::new(),
477 }
478 }
479
480 fn has_symbol(&self, sym: &Symbol) -> bool {
481 self.positions.contains_key(sym)
482 }
483
484 fn position(&self, sym: &Symbol) -> Option<&Position> {
485 self.positions.get(sym)
486 }
487
488 fn position_mut(&mut self, sym: &Symbol) -> &mut Position {
489 self.positions.entry(sym.clone()).or_insert(Position::FLAT)
490 }
491
492 fn set_bracket(&mut self, sym: &Symbol, bracket: Bracket) {
494 self.brackets.insert(sym.clone(), bracket);
495 }
496
497 #[allow(clippy::too_many_arguments)]
506 fn check_bracket(
507 &mut self,
508 sym: &Symbol,
509 candle: &Candle,
510 fees: FeeModel,
511 slippage: SlippageModel,
512 contract_value: f64,
513 when: DateTime<Utc>,
514 trades: &mut Vec<TradeOutcome>,
515 ) -> Option<Fill> {
516 let bracket = self.brackets.get(sym).copied()?;
517 let pos = self.positions.get(sym).copied().unwrap_or(Position::FLAT);
518 if pos.qty == 0.0 || bracket.long != (pos.qty > 0.0) {
520 self.brackets.remove(sym);
521 return None;
522 }
523 let (trigger, close_side) = if bracket.long {
524 if candle.low <= bracket.stop {
525 (bracket.stop, Side::Sell)
526 } else if candle.high >= bracket.take_profit {
527 (bracket.take_profit, Side::Sell)
528 } else {
529 return None;
530 }
531 } else if candle.high >= bracket.stop {
532 (bracket.stop, Side::Buy)
533 } else if candle.low <= bracket.take_profit {
534 (bracket.take_profit, Side::Buy)
535 } else {
536 return None;
537 };
538 let fill_price = slippage.apply(close_side, trigger);
539 let qty = pos.qty.abs();
540 let fee = fees.fee_for(fill_price, qty * contract_value, true);
541 apply_fill(self, sym, close_side, qty, fill_price, fee, contract_value, when, trades);
542 self.brackets.remove(sym);
543 Some(Fill {
544 symbol: sym.clone(),
545 order_id: "bt-bracket".to_string(),
546 client_id: None,
547 side: close_side,
548 price: rustrade_core::Price(fill_price),
549 size: rustrade_core::Volume(qty),
550 fee,
551 fee_currency: "QUOTE".into(),
552 timestamp: when,
553 })
554 }
555
556 fn sample_step(&mut self, sym: &Symbol, close: f64, contract_value: f64) {
560 self.last_marks.insert(sym.clone(), close);
561 let equity = self.equity_now(contract_value);
562
563 if equity > self.equity_hwm {
565 self.equity_hwm = equity;
566 }
567 let dd = equity - self.equity_hwm;
568 if dd < self.max_drawdown {
569 self.max_drawdown = dd;
570 }
571
572 self.equity_curve.push(equity);
573 let prev = self.last_equity;
577 if prev > 0.0 {
578 self.period_returns.push((equity - prev) / prev);
579 } else {
580 self.period_returns.push(0.0);
581 }
582 self.last_equity = equity;
583 }
584
585 fn equity_now(&self, contract_value: f64) -> f64 {
588 let mut equity = self.cash;
589 for (sym, pos) in &self.positions {
590 if let Some(entry) = pos.entry_price
591 && let Some(mark) = self.last_marks.get(sym)
592 {
593 let pnl_per_unit = (mark - entry) * pos.qty.signum();
594 equity += pnl_per_unit * pos.qty.abs() * contract_value;
595 }
596 }
597 equity
598 }
599
600 fn into_returns(self) -> ReturnsSummary {
601 ReturnsSummary {
602 max_drawdown: self.max_drawdown,
603 equity: self.equity_curve,
604 period_returns: self.period_returns,
605 }
606 }
607}
608
609pub(crate) fn validate_candle(c: &Candle) -> std::result::Result<(), String> {
617 for (name, v) in [
618 ("open", c.open),
619 ("high", c.high),
620 ("low", c.low),
621 ("close", c.close),
622 ] {
623 if !v.is_finite() || v <= 0.0 {
624 return Err(format!("{name}={v} (prices must be finite and > 0)"));
625 }
626 }
627 if !c.volume.is_finite() || c.volume < 0.0 {
628 return Err(format!("volume={} (must be finite and >= 0)", c.volume));
629 }
630 Ok(())
631}
632
633struct ResolvedOrder {
635 side: Side,
636 qty: f64,
637 is_close: bool,
638 kind: OrderKind,
639 limit_price: Option<f64>,
642}
643
644#[derive(Clone, Copy)]
649struct Bracket {
650 stop: f64,
652 take_profit: f64,
654 long: bool,
657}
658
659fn record_closes_into_risk(r: &mut SymbolRisk, closes: &[TradeOutcome]) {
664 for t in closes {
665 if let Some(session) = &mut r.session {
666 session.record_close(t.gross_pnl, t.fee);
667 }
668 if let Some(breaker) = &mut r.breaker {
669 let net = t.net_pnl();
670 if net > 0.0 {
671 breaker.record_win();
672 } else if net < 0.0 {
673 breaker.record_loss();
674 }
675 }
676 }
677}
678
679fn resolve_order(
681 decision: &Decision,
682 position: &Position,
683 sizer: &PositionSizer,
684 price: f64,
685 contract_value: f64,
686) -> Option<ResolvedOrder> {
687 match decision.signal {
688 SignalType::Hold => None,
689 SignalType::Close => {
690 let close_side = position.close_side()?;
691 Some(ResolvedOrder {
692 side: close_side,
693 qty: position.qty.abs(),
694 is_close: true,
695 kind: OrderKind::Market,
696 limit_price: None,
697 })
698 }
699 SignalType::Buy | SignalType::Sell => {
700 let side = if matches!(decision.signal, SignalType::Buy) {
701 Side::Buy
702 } else {
703 Side::Sell
704 };
705 let contracts = size_from_hint(sizer, decision.size_hint, price, contract_value);
706 if contracts == 0 {
707 None
708 } else {
709 Some(ResolvedOrder {
710 side,
711 qty: contracts as f64,
712 is_close: false,
713 kind: decision.order_kind,
714 limit_price: decision.limit_price.map(|p| p.value()),
715 })
716 }
717 }
718 }
719}
720
721fn resolve_fill(resolved: &ResolvedOrder, candle: &Candle) -> Option<(f64, bool)> {
735 if resolved.is_close
736 || matches!(
737 resolved.kind,
738 OrderKind::Market | OrderKind::Ioc | OrderKind::Fok
739 )
740 {
741 return Some((candle.close, true));
742 }
743
744 let limit = resolved.limit_price.unwrap_or(candle.close);
745 let (fills, price, marketable) = match resolved.side {
746 Side::Buy => (
747 candle.low <= limit,
748 limit.min(candle.open),
749 limit >= candle.open,
750 ),
751 Side::Sell => (
752 candle.high >= limit,
753 limit.max(candle.open),
754 limit <= candle.open,
755 ),
756 };
757 if !fills {
758 return None;
759 }
760 if matches!(resolved.kind, OrderKind::PostOnly) && marketable {
761 return None;
762 }
763 Some((price, marketable))
764}
765
766fn size_from_hint(sizer: &PositionSizer, hint: SizeHint, price: f64, contract_value: f64) -> u32 {
767 match hint {
768 SizeHint::Default => sizer.contracts(price, contract_value),
769 SizeHint::MarginFraction(f) => {
770 let f = f.clamp(0.0, 1.0);
771 let margin = sizer.config().margin_per_trade * f;
772 sizer.contracts_with_margin(margin, price, contract_value)
773 }
774 SizeHint::NotionalUsd(n) => {
775 let leverage = sizer.config().leverage.max(1);
776 let margin = n / f64::from(leverage);
777 sizer.contracts_with_margin(margin, price, contract_value)
778 }
779 SizeHint::Quantity(q) => {
780 let raw = q.value().max(0.0).floor() as u32;
781 raw.min(sizer.config().max_contracts)
782 }
783 }
784}
785
786#[allow(clippy::too_many_arguments)]
789fn apply_fill(
790 state: &mut State,
791 symbol: &Symbol,
792 side: Side,
793 qty: f64,
794 fill_price: f64,
795 fee: f64,
796 contract_value: f64,
797 when: DateTime<Utc>,
798 trades: &mut Vec<TradeOutcome>,
799) {
800 let signed_qty = match side {
802 Side::Buy => qty,
803 Side::Sell => -qty,
804 };
805
806 let (old_qty, old_entry) = {
807 let p = state.position_mut(symbol);
808 (p.qty, p.entry_price)
809 };
810 let new_qty = old_qty + signed_qty;
811
812 let closing_qty = if old_qty.signum() != signed_qty.signum() && old_qty != 0.0 {
816 old_qty.abs().min(qty)
817 } else {
818 0.0
819 };
820 let opening_qty = qty - closing_qty;
821
822 if closing_qty > 0.0 {
823 let entry = old_entry.unwrap_or(fill_price);
824 let direction = old_qty.signum();
825 let gross = (fill_price - entry) * direction * closing_qty * contract_value;
826 let fee_share = if qty > 0.0 {
829 fee * (closing_qty / qty)
830 } else {
831 0.0
832 };
833 trades.push(TradeOutcome {
834 symbol: symbol.as_str().to_string(),
835 close_side: side,
836 qty: closing_qty,
837 entry_price: entry,
838 exit_price: fill_price,
839 gross_pnl: gross,
840 fee: fee_share,
841 closed_at: when,
842 });
843 state.cash += gross - fee_share;
844 }
845
846 let new_position = if opening_qty > 0.0 {
847 let fee_open = if qty > 0.0 {
849 fee * (opening_qty / qty)
850 } else {
851 0.0
852 };
853 state.cash -= fee_open;
854 let new_position_qty_after_close = old_qty + side_sign(side) * closing_qty;
860 let post_open_qty = new_position_qty_after_close + side_sign(side) * opening_qty;
861 let entry = if new_position_qty_after_close == 0.0 {
862 fill_price
863 } else {
864 let prev_entry = old_entry.unwrap_or(fill_price);
865 let prev_notional = prev_entry * new_position_qty_after_close.abs();
866 let new_notional = fill_price * opening_qty;
867 (prev_notional + new_notional) / post_open_qty.abs()
868 };
869 Position {
870 qty: post_open_qty,
871 entry_price: Some(entry),
872 unrealised_pnl: 0.0,
873 }
874 } else if new_qty == 0.0 {
875 Position::FLAT
876 } else {
877 Position {
878 qty: new_qty,
879 entry_price: old_entry,
880 unrealised_pnl: 0.0,
881 }
882 };
883 *state.position_mut(symbol) = new_position;
884}
885
886fn side_sign(side: Side) -> f64 {
887 match side {
888 Side::Buy => 1.0,
889 Side::Sell => -1.0,
890 }
891}
892
893fn candle_time(c: &Candle) -> DateTime<Utc> {
894 Utc.timestamp_millis_opt(c.time)
895 .single()
896 .unwrap_or_else(Utc::now)
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use async_trait::async_trait;
903 use rustrade_core::{BrainHealth, Decision, MarketDataEvent, Position, Result as CoreResult};
904 use rustrade_risk::SizingConfig;
905
906 struct FixedBrain {
908 signal: SignalType,
909 }
910 #[async_trait]
911 impl Brain for FixedBrain {
912 fn name(&self) -> &str {
913 "fixed"
914 }
915 async fn on_event(&self, _e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
916 Ok(match self.signal {
917 SignalType::Hold => Decision::hold(),
918 SignalType::Buy => Decision::buy(1.0),
919 SignalType::Sell => Decision::sell(1.0),
920 SignalType::Close => Decision::close(),
921 })
922 }
923 async fn health(&self) -> BrainHealth {
924 BrainHealth::ok()
925 }
926 }
927
928 fn flat_series(n: usize, price: f64) -> Vec<Candle> {
929 (0..n)
930 .map(|i| Candle {
931 time: i as i64 * 60_000,
932 open: price,
933 high: price,
934 low: price,
935 close: price,
936 volume: 1.0,
937 })
938 .collect()
939 }
940
941 fn ramp_series(n: usize, start: f64, step: f64) -> Vec<Candle> {
942 (0..n)
943 .map(|i| {
944 let p = start + step * i as f64;
945 Candle {
946 time: i as i64 * 60_000,
947 open: p,
948 high: p,
949 low: p,
950 close: p,
951 volume: 1.0,
952 }
953 })
954 .collect()
955 }
956
957 fn cfg() -> BacktestConfig {
958 BacktestConfig::builder()
959 .symbol("BTCUSDT")
960 .initial_cash(10_000.0)
961 .sizing(SizingConfig {
962 margin_per_trade: 1_000.0,
963 leverage: 1,
964 max_contracts: 100,
965 })
966 .build()
967 .unwrap()
968 }
969
970 #[tokio::test]
971 async fn hold_brain_produces_no_trades() {
972 let result = Backtest::new(
973 cfg(),
974 Arc::new(FixedBrain {
975 signal: SignalType::Hold,
976 }),
977 )
978 .with_candles(flat_series(50, 100.0))
979 .run()
980 .await
981 .unwrap();
982 assert_eq!(result.signals_emitted, 0);
983 assert_eq!(result.orders_filled, 0);
984 assert_eq!(result.trades.len(), 0);
985 assert_eq!(result.net_pnl, 0.0);
986 assert_eq!(result.candles_processed, 50);
987 assert_eq!(result.equity_curve.len(), 51);
990 assert_eq!(result.period_returns.len(), 50);
991 }
992
993 #[tokio::test]
994 async fn buy_then_close_realises_pnl_on_uptrend() {
995 let result = Backtest::new(
999 cfg(),
1000 Arc::new(FixedBrain {
1001 signal: SignalType::Buy,
1002 }),
1003 )
1004 .with_candles(ramp_series(20, 100.0, 1.0))
1005 .run()
1006 .await
1007 .unwrap();
1008 assert_eq!(result.orders_filled, 20);
1011 assert_eq!(result.trades.len(), 0);
1013 assert_eq!(result.net_pnl, 0.0);
1014 }
1015
1016 #[tokio::test]
1017 async fn determinism_two_runs_same_inputs() {
1018 let series = ramp_series(30, 100.0, 0.5);
1019 let r1 = Backtest::new(
1020 cfg(),
1021 Arc::new(FixedBrain {
1022 signal: SignalType::Buy,
1023 }),
1024 )
1025 .with_candles(series.clone())
1026 .run()
1027 .await
1028 .unwrap();
1029 let r2 = Backtest::new(
1030 cfg(),
1031 Arc::new(FixedBrain {
1032 signal: SignalType::Buy,
1033 }),
1034 )
1035 .with_candles(series)
1036 .run()
1037 .await
1038 .unwrap();
1039 assert_eq!(r1.candles_processed, r2.candles_processed);
1040 assert_eq!(r1.signals_emitted, r2.signals_emitted);
1041 assert_eq!(r1.orders_filled, r2.orders_filled);
1042 assert_eq!(r1.trades.len(), r2.trades.len());
1043 assert!((r1.net_pnl - r2.net_pnl).abs() < 1e-12);
1044 assert_eq!(r1.equity_curve, r2.equity_curve);
1045 }
1046
1047 #[tokio::test]
1048 async fn close_against_flat_is_noop() {
1049 let result = Backtest::new(
1050 cfg(),
1051 Arc::new(FixedBrain {
1052 signal: SignalType::Close,
1053 }),
1054 )
1055 .with_candles(flat_series(10, 100.0))
1056 .run()
1057 .await
1058 .unwrap();
1059 assert_eq!(result.orders_filled, 0);
1060 assert_eq!(result.trades.len(), 0);
1061 }
1062
1063 #[test]
1064 fn merge_series_interleaves_by_timestamp() {
1065 let s1 = Symbol::from("AAA");
1066 let s2 = Symbol::from("BBB");
1067 let series = vec![
1068 (
1069 s1.clone(),
1070 vec![
1071 Candle {
1072 time: 1000,
1073 open: 1.0,
1074 high: 1.0,
1075 low: 1.0,
1076 close: 1.0,
1077 volume: 0.0,
1078 },
1079 Candle {
1080 time: 3000,
1081 open: 1.0,
1082 high: 1.0,
1083 low: 1.0,
1084 close: 1.0,
1085 volume: 0.0,
1086 },
1087 ],
1088 ),
1089 (
1090 s2.clone(),
1091 vec![
1092 Candle {
1093 time: 2000,
1094 open: 2.0,
1095 high: 2.0,
1096 low: 2.0,
1097 close: 2.0,
1098 volume: 0.0,
1099 },
1100 Candle {
1101 time: 3000,
1102 open: 2.0,
1103 high: 2.0,
1104 low: 2.0,
1105 close: 2.0,
1106 volume: 0.0,
1107 },
1108 ],
1109 ),
1110 ];
1111 let merged = merge_series(&series);
1112 let times: Vec<i64> = merged.iter().map(|(_, c)| c.time).collect();
1113 assert_eq!(times, vec![1000, 2000, 3000, 3000]);
1114 assert_eq!(merged[2].0, s1);
1116 assert_eq!(merged[3].0, s2);
1117 }
1118
1119 #[tokio::test]
1120 async fn multi_symbol_routes_to_each_symbol_state() {
1121 struct SymBrain;
1124 #[async_trait]
1125 impl Brain for SymBrain {
1126 fn name(&self) -> &str {
1127 "sym"
1128 }
1129 async fn on_event(&self, e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
1130 match e.symbol().as_str() {
1131 "AAA" => Ok(Decision::buy(1.0)),
1132 "BBB" => Ok(Decision::sell(1.0)),
1133 _ => Ok(Decision::hold()),
1134 }
1135 }
1136 async fn health(&self) -> BrainHealth {
1137 BrainHealth::ok()
1138 }
1139 }
1140
1141 let cfg = BacktestConfig::builder()
1142 .symbols(["AAA", "BBB"])
1143 .initial_cash(100_000.0)
1144 .sizing(SizingConfig {
1145 margin_per_trade: 1_000.0,
1146 leverage: 1,
1147 max_contracts: 100,
1148 })
1149 .build()
1150 .unwrap();
1151 let result = Backtest::new(cfg, Arc::new(SymBrain))
1152 .with_symbol_candles("AAA", flat_series(5, 100.0))
1153 .with_symbol_candles("BBB", flat_series(5, 200.0))
1154 .run()
1155 .await
1156 .unwrap();
1157 assert_eq!(result.candles_processed, 10);
1159 assert_eq!(result.orders_filled, 10);
1160 assert_eq!(result.trades.len(), 0);
1162 assert_eq!(result.symbol, "AAA,BBB");
1164 }
1165
1166 fn good_candle() -> Candle {
1169 Candle {
1170 time: 0,
1171 open: 1.0,
1172 high: 1.0,
1173 low: 1.0,
1174 close: 1.0,
1175 volume: 1.0,
1176 }
1177 }
1178
1179 #[test]
1180 fn validate_candle_accepts_finite_positive() {
1181 assert!(validate_candle(&good_candle()).is_ok());
1182 let c = Candle {
1184 volume: 0.0,
1185 ..good_candle()
1186 };
1187 assert!(validate_candle(&c).is_ok());
1188 }
1189
1190 #[test]
1191 fn validate_candle_rejects_non_finite_and_non_positive_prices() {
1192 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1193 let c = Candle {
1194 close: bad,
1195 ..good_candle()
1196 };
1197 assert!(
1198 validate_candle(&c).is_err(),
1199 "close={bad} should be rejected"
1200 );
1201 }
1202 }
1203
1204 #[test]
1205 fn validate_candle_rejects_negative_or_nan_volume() {
1206 for bad in [-1.0, f64::NAN, f64::INFINITY] {
1207 let c = Candle {
1208 volume: bad,
1209 ..good_candle()
1210 };
1211 assert!(
1212 validate_candle(&c).is_err(),
1213 "volume={bad} should be rejected"
1214 );
1215 }
1216 }
1217
1218 #[tokio::test]
1219 async fn run_rejects_non_finite_candle() {
1220 let mut series = flat_series(5, 100.0);
1224 series[2].close = f64::NAN;
1225 let err = Backtest::new(
1226 cfg(),
1227 Arc::new(FixedBrain {
1228 signal: SignalType::Hold,
1229 }),
1230 )
1231 .with_candles(series)
1232 .run()
1233 .await
1234 .unwrap_err();
1235 assert!(matches!(err, Error::Data(_)), "got {err:?}");
1236 }
1237
1238 #[tokio::test]
1239 async fn multi_symbol_equity_curve_deterministic_across_runs() {
1240 struct DualLong;
1247 #[async_trait]
1248 impl Brain for DualLong {
1249 fn name(&self) -> &str {
1250 "dual-long"
1251 }
1252 async fn on_event(&self, e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
1253 if p.qty == 0.0 && matches!(e, MarketDataEvent::Candle { .. }) {
1254 Ok(Decision::buy(1.0))
1255 } else {
1256 Ok(Decision::hold())
1257 }
1258 }
1259 async fn health(&self) -> BrainHealth {
1260 BrainHealth::ok()
1261 }
1262 }
1263
1264 let run = || async {
1265 let cfg = BacktestConfig::builder()
1266 .symbols(["AAA", "BBB", "CCC"])
1267 .initial_cash(1_000_000.0)
1268 .sizing(SizingConfig {
1269 margin_per_trade: 1_000.0,
1270 leverage: 1,
1271 max_contracts: 100,
1272 })
1273 .build()
1274 .unwrap();
1275 Backtest::new(cfg, Arc::new(DualLong))
1276 .with_symbol_candles("AAA", ramp_series(40, 100.13, 0.37))
1277 .with_symbol_candles("BBB", ramp_series(40, 250.07, -0.19))
1278 .with_symbol_candles("CCC", ramp_series(40, 33.31, 0.53))
1279 .run()
1280 .await
1281 .unwrap()
1282 };
1283
1284 let r1 = run().await;
1285 let r2 = run().await;
1286 assert_eq!(r1.equity_curve, r2.equity_curve);
1288 assert_eq!(r1.period_returns, r2.period_returns);
1289 assert_eq!(r1.net_pnl.to_bits(), r2.net_pnl.to_bits());
1290 assert_eq!(r1.max_drawdown.to_bits(), r2.max_drawdown.to_bits());
1291 }
1292}