Skip to main content

rustrade_backtest/
engine.rs

1//! The replay engine.
2//!
3//! Single-threaded loop: feeds candles to the brain in order, builds an
4//! order from each non-`Hold` decision, applies slippage and fees, and
5//! updates a synthetic per-symbol position. On position-reducing fills
6//! (closes or flips) a [`TradeOutcome`] is emitted into the result.
7//!
8//! # Multi-symbol replay
9//!
10//! Each candle is tagged with a symbol. The engine maintains independent
11//! [`Position`] state per symbol but a *single* shared cash balance. When
12//! [`Backtest::with_candles`] is used, every candle is assumed to be for
13//! the (single) symbol on the config. For multi-symbol runs use
14//! [`Backtest::with_symbol_candles`].
15
16use 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
31/// The replay engine itself. Configure via [`BacktestConfig`], attach a
32/// [`Brain`] and one or more candle series, then `.run().await` for the
33/// result.
34///
35/// # Example
36///
37/// ```no_run
38/// # use std::sync::Arc;
39/// use rustrade_backtest::{Backtest, BacktestConfig, load_csv};
40/// # async fn run(brain: Arc<dyn rustrade_core::Brain>) -> rustrade_backtest::Result<()> {
41/// let candles = load_csv("data/btcusdt-1m.csv")?;
42/// let result = Backtest::new(
43///     BacktestConfig::builder()
44///         .symbol("BTCUSDT")
45///         .initial_cash(10_000.0)
46///         .build()?,
47///     brain,
48/// )
49/// .with_candles(candles)
50/// .run()
51/// .await?;
52///
53/// println!("{}", result.summary());
54/// # Ok(())
55/// # }
56/// ```
57pub struct Backtest {
58    config: BacktestConfig,
59    brain: Arc<dyn Brain>,
60    /// Per-symbol candle series. Within each series candles are assumed
61    /// to be in chronological order; across series the engine merges
62    /// chronologically before replay.
63    series: Vec<(Symbol, Vec<Candle>)>,
64}
65
66impl Backtest {
67    /// Construct with a config + brain. The candle series is attached
68    /// separately via [`Self::with_candles`] / [`Self::with_symbol_candles`].
69    pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
70        Self {
71            config,
72            brain,
73            series: Vec::new(),
74        }
75    }
76
77    /// Feed a candle series for the (single) symbol on the config.
78    /// Convenience wrapper around [`Self::with_symbol_candles`].
79    ///
80    /// Panics if the config has more than one symbol — use
81    /// [`Self::with_symbol_candles`] for multi-symbol backtests.
82    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    /// Feed a candle series for a specific symbol. Call multiple times
96    /// for multi-symbol backtests; repeated calls for the same symbol
97    /// replace the previous series.
98    ///
99    /// The engine merges all series chronologically before replay, so
100    /// the brain sees the global event stream — not per-symbol blocks.
101    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    /// Run the backtest to completion. Returns the aggregated result.
109    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        // Reject non-finite / non-positive prices up front. A single
117        // `NaN` close would otherwise propagate through the equity curve
118        // and silently turn every downstream metric (Sharpe, Sortino,
119        // drawdown) into `NaN`. Fail loud instead.
120        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            // Brains see the live position at decision time — same as
145            // the live `ExecutionService` does. For symbols not in the
146            // config we still route the event through the brain (so
147            // multi-symbol brains can filter) but treat the position as
148            // FLAT and skip any orders.
149            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            // Translate the decision into a concrete fill request. For
165            // `Close` we use the existing position size. For Buy/Sell we
166            // size from the brain's hint just like ExecutionService, and
167            // carry the requested order kind + limit price.
168            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            // Decide the fill by order kind. Market / IOC / FOK and closes
184            // are immediate takers at the candle close. Limit / PostOnly
185            // rest and fill only if this candle's range crosses the limit
186            // (a post-only that would cross as taker is rejected). A limit
187            // that doesn't cross is treated as unfilled and dropped — orders
188            // that rest across candles or partially fill need an order book
189            // (a 0.4a item).
190            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            // Slippage applies to taker fills (crossing the spread); a
195            // resting maker fills at its limit price exactly.
196            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            // Update position state. If this fill reduces or flips the
208            // position, emit one or more TradeOutcomes.
209            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            // Inform the brain of the (synthetic) fill — same callback
224            // the live `FillRoutingService` would invoke.
225            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            // Stable, deterministic label for multi-symbol runs.
250            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
274// ── Series merging ──────────────────────────────────────────────────────
275
276/// Merge per-symbol candle series into a chronological `(symbol, candle)`
277/// stream. Stable for equal timestamps: ties preserve the *order the
278/// series were attached*, then the order candles appear within their
279/// series. This keeps multi-symbol runs deterministic even if two
280/// exchanges produce identical timestamps.
281fn 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    // Sort by (time, series order) — stable for matching timestamps
290    // within the same series.
291    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
295// ── State + helpers ─────────────────────────────────────────────────────
296
297/// Mutable in-loop state: per-symbol position, shared realised cash,
298/// equity HWM, drawdown, and the equity / per-period returns sample
299/// stream used by Sharpe / Sortino.
300struct State {
301    // `BTreeMap`, not `HashMap`: `equity_now` sums unrealised PnL by
302    // iterating this map, and float addition is not associative. A
303    // `HashMap`'s per-process-randomized iteration order would make a
304    // multi-symbol equity curve (and thus Sharpe/Sortino) differ in the
305    // last ULP between otherwise-identical runs, breaking the engine's
306    // determinism guarantee. `BTreeMap` iterates in sorted symbol order,
307    // so the summation order is fixed across runs.
308    positions: BTreeMap<Symbol, Position>,
309    cash: f64,
310    equity_hwm: f64,
311    max_drawdown: f64,
312    // Sampled once per candle in `sample_step`, so Sharpe/Sortino see
313    // the full price path even on Hold ticks.
314    last_equity: f64,
315    equity_curve: Vec<f64>,
316    period_returns: Vec<f64>,
317    // Cached marks per symbol (last close seen) so we can compute the
318    // total portfolio equity at any sample boundary even when only one
319    // symbol's price has just changed. `BTreeMap` for the same
320    // determinism reason as `positions`.
321    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    /// Record the latest close for a symbol, then sample portfolio
361    /// equity. The equity curve always grows by one sample per candle —
362    /// even on `Hold` ticks — so Sharpe/Sortino see the full price path.
363    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        // Drawdown bookkeeping.
368        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        // Per-period simple return on prior equity (skip the first
378        // sample — no prior period). Use prior equity to avoid divide-
379        // by-zero on a fully-drained account.
380        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    /// Total portfolio equity = realised cash + sum of unrealised PnL
390    /// across all symbols using their last-known marks.
391    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
413/// Validate a candle's OHLCV fields are usable: prices finite and
414/// strictly positive, volume finite and non-negative. Returns a
415/// human-readable reason on the first offending field.
416///
417/// OHLC *ordering* (`high >= low`, etc.) is intentionally not enforced —
418/// the goal is to keep `NaN`/`inf`/negative values out of the
419/// mark-to-market math, not to police exchange data quality.
420pub(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
437/// A decision resolved into a concrete fill request for the engine.
438struct ResolvedOrder {
439    side: Side,
440    qty: f64,
441    is_close: bool,
442    kind: OrderKind,
443    /// Limit price (quote currency) for resting kinds; `None` for market
444    /// and close orders.
445    limit_price: Option<f64>,
446}
447
448/// Resolve a `Decision` into a [`ResolvedOrder`].
449fn 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
490/// Decide whether and at what reference price a resolved order fills on
491/// `candle`. Returns `(reference_price, is_taker)`; the caller applies
492/// slippage to taker fills. Returns `None` when a resting limit doesn't
493/// cross this candle (or a post-only would cross as taker).
494///
495/// - **Market / IOC / FOK and closes** fill immediately at the candle
496///   close as takers.
497/// - **Limit / PostOnly** rest: a buy fills when `low` trades down to the
498///   limit, a sell when `high` trades up to it. A limit already marketable
499///   at the candle open fills at the open as a taker (a post-only in that
500///   case is rejected); a non-marketable limit fills at its limit price as
501///   a maker. A missing limit falls back to the close, mirroring the live
502///   execution layer's event-price fallback.
503fn 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/// Apply a fill to the synthetic position. Emits one [`TradeOutcome`]
556/// per closed quantity (so a flip from +5 to -3 emits one close-5 trade).
557#[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    // Signed delta to the position quantity from this fill.
570    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    // The realised-PnL portion is whatever quantity *reduces* the
582    // existing position. Anything beyond that opens a new position in
583    // the opposite direction.
584    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        // Fee is apportioned by closing fraction so a single fill that
596        // both closes and reopens charges fees pro-rata to each side.
597        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        // The fee component charged to opening.
617        let fee_open = if qty > 0.0 {
618            fee * (opening_qty / qty)
619        } else {
620            0.0
621        };
622        state.cash -= fee_open;
623        // New entry price: if we were FLAT or fully closed first, this
624        // is the fresh entry; if we'd somehow added to an existing
625        // position (same-side fill), it's a weighted average. Brains
626        // don't pyramid in normal use since they emit one direction at
627        // a time, but handle it correctly anyway.
628        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    /// Brain that always emits the configured signal.
676    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        // Equity curve always seeds the initial cash, then one sample
757        // per candle.
758        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        // Buy on every candle. Position opens once; subsequent buys add
765        // to it (pyramiding). Test just runs to completion and asserts
766        // we accumulated *some* position and saw no trade close yet.
767        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        // Every candle emits Buy → orders_filled equals candle count
778        // (sizer always returns ≥ 1 contract here).
779        assert_eq!(result.orders_filled, 20);
780        // No reducing fills yet → no completed trades.
781        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        // Tie at t=3000 is broken by series-insertion order — AAA first.
884        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        // Brain that goes long on AAA and short on BBB; FlipBrain takes
891        // both sides simultaneously to verify per-symbol position state.
892        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        // 5 AAA + 5 BBB = 10 orders (every candle, both symbols).
927        assert_eq!(result.candles_processed, 10);
928        assert_eq!(result.orders_filled, 10);
929        // No closes — no completed trades yet.
930        assert_eq!(result.trades.len(), 0);
931        // Symbol label is the concatenated list.
932        assert_eq!(result.symbol, "AAA,BBB");
933    }
934
935    // ── Candle validation ───────────────────────────────────────────────
936
937    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        // Zero volume is legitimate (illiquid candle).
952        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        // A NaN close passed straight through `with_candles` (bypassing
990        // the CSV loader) must still be caught — otherwise it poisons
991        // the equity curve and every metric silently.
992        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        // Regression test for the HashMap-iteration-order determinism
1010        // bug: with two simultaneously-open positions, `equity_now` sums
1011        // unrealised PnL across the per-symbol map. Float addition isn't
1012        // associative, so a randomized map order would make the equity
1013        // curve differ run-to-run. `BTreeMap` fixes the order. Prices
1014        // are deliberately awkward so reordering *would* change the ULPs.
1015        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        // Bit-exact across runs — not just approximately equal.
1056        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}