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::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
32/// The replay engine itself. Configure via [`BacktestConfig`], attach a
33/// [`Brain`] and one or more candle series, then `.run().await` for the
34/// result.
35///
36/// # Example
37///
38/// ```no_run
39/// # use std::sync::Arc;
40/// use rustrade_backtest::{Backtest, BacktestConfig, load_csv};
41/// # async fn run(brain: Arc<dyn rustrade_core::Brain>) -> rustrade_backtest::Result<()> {
42/// let candles = load_csv("data/btcusdt-1m.csv")?;
43/// let result = Backtest::new(
44///     BacktestConfig::builder()
45///         .symbol("BTCUSDT")
46///         .initial_cash(10_000.0)
47///         .build()?,
48///     brain,
49/// )
50/// .with_candles(candles)
51/// .run()
52/// .await?;
53///
54/// println!("{}", result.summary());
55/// # Ok(())
56/// # }
57/// ```
58pub struct Backtest {
59    config: BacktestConfig,
60    brain: Arc<dyn Brain>,
61    /// Per-symbol candle series. Within each series candles are assumed
62    /// to be in chronological order; across series the engine merges
63    /// chronologically before replay.
64    series: Vec<(Symbol, Vec<Candle>)>,
65}
66
67impl Backtest {
68    /// Construct with a config + brain. The candle series is attached
69    /// separately via [`Self::with_candles`] / [`Self::with_symbol_candles`].
70    pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
71        Self {
72            config,
73            brain,
74            series: Vec::new(),
75        }
76    }
77
78    /// Feed a candle series for the (single) symbol on the config.
79    /// Convenience wrapper around [`Self::with_symbol_candles`].
80    ///
81    /// Panics if the config has more than one symbol — use
82    /// [`Self::with_symbol_candles`] for multi-symbol backtests.
83    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    /// Feed a candle series for a specific symbol. Call multiple times
97    /// for multi-symbol backtests; repeated calls for the same symbol
98    /// replace the previous series.
99    ///
100    /// The engine merges all series chronologically before replay, so
101    /// the brain sees the global event stream — not per-symbol blocks.
102    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    /// Run the backtest to completion. Returns the aggregated result.
110    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        // Reject non-finite / non-positive prices up front. A single
118        // `NaN` close would otherwise propagate through the equity curve
119        // and silently turn every downstream metric (Sharpe, Sortino,
120        // drawdown) into `NaN`. Fail loud instead.
121        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        // Per-symbol risk gates (session-PnL halt + circuit breaker),
140        // mirroring the live execution path's gate sequence. Time is
141        // driven by candle timestamps through a shared `ManualClock`, so
142        // the UTC-midnight rollover and the breaker's window/cooldown run
143        // on replay time and the run stays deterministic.
144        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            // Advance replay time and roll the risk state over before any
168            // decision on this candle — the live counterpart is the
169            // periodic `RiskSweepService` tick.
170            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            // Brains see the live position at decision time — same as
183            // the live `ExecutionService` does. For symbols not in the
184            // config we still route the event through the brain (so
185            // multi-symbol brains can filter) but treat the position as
186            // FLAT and skip any orders.
187            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            // ── Risk gates (parity with the live ExecutionService) ────
203            // Gate 1: session-PnL halt; gate 2: circuit breaker. Like
204            // live, these block *every* non-Hold decision — including
205            // `Close` — after the signal is counted.
206            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            // Translate the decision into a concrete fill request. For
219            // `Close` we use the existing position size. For Buy/Sell we
220            // size from the brain's hint just like ExecutionService, and
221            // carry the requested order kind + limit price.
222            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            // Decide the fill by order kind. Market / IOC / FOK and closes
238            // are immediate takers at the candle close. Limit / PostOnly
239            // rest and fill only if this candle's range crosses the limit
240            // (a post-only that would cross as taker is rejected). A limit
241            // that doesn't cross is treated as unfilled and dropped — orders
242            // that rest across candles or partially fill need an order book
243            // (a 0.4a item).
244            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            // Slippage applies to taker fills (crossing the spread); a
249            // resting maker fills at its limit price exactly.
250            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            // Update position state. If this fill reduces or flips the
262            // position, emit one or more TradeOutcomes.
263            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            // Feed each realised close into the risk gates — the live
277            // counterpart is `FillRoutingService`'s auto-PnL recording.
278            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            // Inform the brain of the (synthetic) fill — same callback
297            // the live `FillRoutingService` would invoke.
298            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            // Stable, deterministic label for multi-symbol runs.
323            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
348/// Per-symbol risk gates for replay: the same primitives the live bot
349/// runs, time-driven by the shared replay clock.
350struct SymbolRisk {
351    session: Option<SessionPnl>,
352    breaker: Option<CircuitBreaker>,
353}
354
355impl SymbolRisk {
356    /// Roll both primitives forward to the current replay time.
357    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
367// ── Series merging ──────────────────────────────────────────────────────
368
369/// Merge per-symbol candle series into a chronological `(symbol, candle)`
370/// stream. Stable for equal timestamps: ties preserve the *order the
371/// series were attached*, then the order candles appear within their
372/// series. This keeps multi-symbol runs deterministic even if two
373/// exchanges produce identical timestamps.
374fn 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    // Sort by (time, series order) — stable for matching timestamps
383    // within the same series.
384    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
388// ── State + helpers ─────────────────────────────────────────────────────
389
390/// Mutable in-loop state: per-symbol position, shared realised cash,
391/// equity HWM, drawdown, and the equity / per-period returns sample
392/// stream used by Sharpe / Sortino.
393struct State {
394    // `BTreeMap`, not `HashMap`: `equity_now` sums unrealised PnL by
395    // iterating this map, and float addition is not associative. A
396    // `HashMap`'s per-process-randomized iteration order would make a
397    // multi-symbol equity curve (and thus Sharpe/Sortino) differ in the
398    // last ULP between otherwise-identical runs, breaking the engine's
399    // determinism guarantee. `BTreeMap` iterates in sorted symbol order,
400    // so the summation order is fixed across runs.
401    positions: BTreeMap<Symbol, Position>,
402    cash: f64,
403    equity_hwm: f64,
404    max_drawdown: f64,
405    // Sampled once per candle in `sample_step`, so Sharpe/Sortino see
406    // the full price path even on Hold ticks.
407    last_equity: f64,
408    equity_curve: Vec<f64>,
409    period_returns: Vec<f64>,
410    // Cached marks per symbol (last close seen) so we can compute the
411    // total portfolio equity at any sample boundary even when only one
412    // symbol's price has just changed. `BTreeMap` for the same
413    // determinism reason as `positions`.
414    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    /// Record the latest close for a symbol, then sample portfolio
454    /// equity. The equity curve always grows by one sample per candle —
455    /// even on `Hold` ticks — so Sharpe/Sortino see the full price path.
456    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        // Drawdown bookkeeping.
461        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        // Per-period simple return on prior equity (skip the first
471        // sample — no prior period). Use prior equity to avoid divide-
472        // by-zero on a fully-drained account.
473        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    /// Total portfolio equity = realised cash + sum of unrealised PnL
483    /// across all symbols using their last-known marks.
484    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
506/// Validate a candle's OHLCV fields are usable: prices finite and
507/// strictly positive, volume finite and non-negative. Returns a
508/// human-readable reason on the first offending field.
509///
510/// OHLC *ordering* (`high >= low`, etc.) is intentionally not enforced —
511/// the goal is to keep `NaN`/`inf`/negative values out of the
512/// mark-to-market math, not to police exchange data quality.
513pub(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
530/// A decision resolved into a concrete fill request for the engine.
531struct ResolvedOrder {
532    side: Side,
533    qty: f64,
534    is_close: bool,
535    kind: OrderKind,
536    /// Limit price (quote currency) for resting kinds; `None` for market
537    /// and close orders.
538    limit_price: Option<f64>,
539}
540
541/// Resolve a `Decision` into a [`ResolvedOrder`].
542fn 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
583/// Decide whether and at what reference price a resolved order fills on
584/// `candle`. Returns `(reference_price, is_taker)`; the caller applies
585/// slippage to taker fills. Returns `None` when a resting limit doesn't
586/// cross this candle (or a post-only would cross as taker).
587///
588/// - **Market / IOC / FOK and closes** fill immediately at the candle
589///   close as takers.
590/// - **Limit / PostOnly** rest: a buy fills when `low` trades down to the
591///   limit, a sell when `high` trades up to it. A limit already marketable
592///   at the candle open fills at the open as a taker (a post-only in that
593///   case is rejected); a non-marketable limit fills at its limit price as
594///   a maker. A missing limit falls back to the close, mirroring the live
595///   execution layer's event-price fallback.
596fn 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/// Apply a fill to the synthetic position. Emits one [`TradeOutcome`]
649/// per closed quantity (so a flip from +5 to -3 emits one close-5 trade).
650#[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    // Signed delta to the position quantity from this fill.
663    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    // The realised-PnL portion is whatever quantity *reduces* the
675    // existing position. Anything beyond that opens a new position in
676    // the opposite direction.
677    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        // Fee is apportioned by closing fraction so a single fill that
689        // both closes and reopens charges fees pro-rata to each side.
690        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        // The fee component charged to opening.
710        let fee_open = if qty > 0.0 {
711            fee * (opening_qty / qty)
712        } else {
713            0.0
714        };
715        state.cash -= fee_open;
716        // New entry price: if we were FLAT or fully closed first, this
717        // is the fresh entry; if we'd somehow added to an existing
718        // position (same-side fill), it's a weighted average. Brains
719        // don't pyramid in normal use since they emit one direction at
720        // a time, but handle it correctly anyway.
721        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    /// Brain that always emits the configured signal.
769    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        // Equity curve always seeds the initial cash, then one sample
850        // per candle.
851        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        // Buy on every candle. Position opens once; subsequent buys add
858        // to it (pyramiding). Test just runs to completion and asserts
859        // we accumulated *some* position and saw no trade close yet.
860        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        // Every candle emits Buy → orders_filled equals candle count
871        // (sizer always returns ≥ 1 contract here).
872        assert_eq!(result.orders_filled, 20);
873        // No reducing fills yet → no completed trades.
874        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        // Tie at t=3000 is broken by series-insertion order — AAA first.
977        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        // Brain that goes long on AAA and short on BBB; FlipBrain takes
984        // both sides simultaneously to verify per-symbol position state.
985        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        // 5 AAA + 5 BBB = 10 orders (every candle, both symbols).
1020        assert_eq!(result.candles_processed, 10);
1021        assert_eq!(result.orders_filled, 10);
1022        // No closes — no completed trades yet.
1023        assert_eq!(result.trades.len(), 0);
1024        // Symbol label is the concatenated list.
1025        assert_eq!(result.symbol, "AAA,BBB");
1026    }
1027
1028    // ── Candle validation ───────────────────────────────────────────────
1029
1030    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        // Zero volume is legitimate (illiquid candle).
1045        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        // A NaN close passed straight through `with_candles` (bypassing
1083        // the CSV loader) must still be caught — otherwise it poisons
1084        // the equity curve and every metric silently.
1085        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        // Regression test for the HashMap-iteration-order determinism
1103        // bug: with two simultaneously-open positions, `equity_now` sums
1104        // unrealised PnL across the per-symbol map. Float addition isn't
1105        // associative, so a randomized map order would make the equity
1106        // curve differ run-to-run. `BTreeMap` fixes the order. Prices
1107        // are deliberately awkward so reordering *would* change the ULPs.
1108        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        // Bit-exact across runs — not just approximately equal.
1149        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}