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::fees::FeeModel;
30use crate::metrics::TradeOutcome;
31use crate::result::BacktestResult;
32use crate::slippage::SlippageModel;
33
34/// The replay engine itself. Configure via [`BacktestConfig`], attach a
35/// [`Brain`] and one or more candle series, then `.run().await` for the
36/// result.
37///
38/// # Example
39///
40/// ```no_run
41/// # use std::sync::Arc;
42/// use rustrade_backtest::{Backtest, BacktestConfig, load_csv};
43/// # async fn run(brain: Arc<dyn rustrade_core::Brain>) -> rustrade_backtest::Result<()> {
44/// let candles = load_csv("data/btcusdt-1m.csv")?;
45/// let result = Backtest::new(
46///     BacktestConfig::builder()
47///         .symbol("BTCUSDT")
48///         .initial_cash(10_000.0)
49///         .build()?,
50///     brain,
51/// )
52/// .with_candles(candles)
53/// .run()
54/// .await?;
55///
56/// println!("{}", result.summary());
57/// # Ok(())
58/// # }
59/// ```
60pub struct Backtest {
61    config: BacktestConfig,
62    brain: Arc<dyn Brain>,
63    /// Per-symbol candle series. Within each series candles are assumed
64    /// to be in chronological order; across series the engine merges
65    /// chronologically before replay.
66    series: Vec<(Symbol, Vec<Candle>)>,
67}
68
69impl Backtest {
70    /// Construct with a config + brain. The candle series is attached
71    /// separately via [`Self::with_candles`] / [`Self::with_symbol_candles`].
72    pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
73        Self {
74            config,
75            brain,
76            series: Vec::new(),
77        }
78    }
79
80    /// Feed a candle series for the (single) symbol on the config.
81    /// Convenience wrapper around [`Self::with_symbol_candles`].
82    ///
83    /// Panics if the config has more than one symbol — use
84    /// [`Self::with_symbol_candles`] for multi-symbol backtests.
85    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    /// Feed a candle series for a specific symbol. Call multiple times
99    /// for multi-symbol backtests; repeated calls for the same symbol
100    /// replace the previous series.
101    ///
102    /// The engine merges all series chronologically before replay, so
103    /// the brain sees the global event stream — not per-symbol blocks.
104    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    /// Run the backtest to completion. Returns the aggregated result.
112    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        // Reject non-finite / non-positive prices up front. A single
120        // `NaN` close would otherwise propagate through the equity curve
121        // and silently turn every downstream metric (Sharpe, Sortino,
122        // drawdown) into `NaN`. Fail loud instead.
123        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        // Per-symbol risk gates (session-PnL halt + circuit breaker),
142        // mirroring the live execution path's gate sequence. Time is
143        // driven by candle timestamps through a shared `ManualClock`, so
144        // the UTC-midnight rollover and the breaker's window/cooldown run
145        // on replay time and the run stays deterministic.
146        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            // Advance replay time and roll the risk state over before any
170            // decision on this candle — the live counterpart is the
171            // periodic `RiskSweepService` tick.
172            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            // ── Resting protective brackets fill first ──────────────────────
180            // Before this candle's decision, fire any SL/TP leg whose level the
181            // candle's range crosses — the live counterpart is the exchange
182            // filling the reduce-only OCO legs placed at entry. A stop-out loss
183            // still feeds the risk gates, same as a manual close.
184            {
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            // Brains see the live position at decision time — same as
213            // the live `ExecutionService` does. For symbols not in the
214            // config we still route the event through the brain (so
215            // multi-symbol brains can filter) but treat the position as
216            // FLAT and skip any orders.
217            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            // ── Risk gates (parity with the live ExecutionService) ────
233            // Gate 1: session-PnL halt; gate 2: circuit breaker. Like
234            // live, these block *every* non-Hold decision — including
235            // `Close` — after the signal is counted.
236            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            // Translate the decision into a concrete fill request. For
249            // `Close` we use the existing position size. For Buy/Sell we
250            // size from the brain's hint just like ExecutionService, and
251            // carry the requested order kind + limit price.
252            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            // Decide the fill by order kind. Market / IOC / FOK and closes
268            // are immediate takers at the candle close. Limit / PostOnly
269            // rest and fill only if this candle's range crosses the limit
270            // (a post-only that would cross as taker is rejected). A limit
271            // that doesn't cross is treated as unfilled and dropped — orders
272            // that rest across candles or partially fill need an order book
273            // (a 0.4a item).
274            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            // Slippage applies to taker fills (crossing the spread); a
279            // resting maker fills at its limit price exactly.
280            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            // Update position state. If this fill reduces or flips the
292            // position, emit one or more TradeOutcomes.
293            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            // Feed each realised close into the risk gates — the live
307            // counterpart is `FillRoutingService`'s auto-PnL recording.
308            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            // Inform the brain of the (synthetic) fill — same callback
315            // the live `FillRoutingService` would invoke.
316            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            // Record a simulated OCO bracket for a freshly opened bracketed
333            // market entry (mirrors the live ExecutionService::place_brackets).
334            // It is fired intra-candle on later candles; cleared when flat.
335            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            // Stable, deterministic label for multi-symbol runs.
356            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
381/// Per-symbol risk gates for replay: the same primitives the live bot
382/// runs, time-driven by the shared replay clock.
383struct SymbolRisk {
384    session: Option<SessionPnl>,
385    breaker: Option<CircuitBreaker>,
386}
387
388impl SymbolRisk {
389    /// Roll both primitives forward to the current replay time.
390    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
400// ── Series merging ──────────────────────────────────────────────────────
401
402/// Merge per-symbol candle series into a chronological `(symbol, candle)`
403/// stream. Stable for equal timestamps: ties preserve the *order the
404/// series were attached*, then the order candles appear within their
405/// series. This keeps multi-symbol runs deterministic even if two
406/// exchanges produce identical timestamps.
407fn 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    // Sort by (time, series order) — stable for matching timestamps
416    // within the same series.
417    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
421// ── State + helpers ─────────────────────────────────────────────────────
422
423/// Mutable in-loop state: per-symbol position, shared realised cash,
424/// equity HWM, drawdown, and the equity / per-period returns sample
425/// stream used by Sharpe / Sortino.
426struct State {
427    // `BTreeMap`, not `HashMap`: `equity_now` sums unrealised PnL by
428    // iterating this map, and float addition is not associative. A
429    // `HashMap`'s per-process-randomized iteration order would make a
430    // multi-symbol equity curve (and thus Sharpe/Sortino) differ in the
431    // last ULP between otherwise-identical runs, breaking the engine's
432    // determinism guarantee. `BTreeMap` iterates in sorted symbol order,
433    // so the summation order is fixed across runs.
434    positions: BTreeMap<Symbol, Position>,
435    cash: f64,
436    equity_hwm: f64,
437    max_drawdown: f64,
438    // Sampled once per candle in `sample_step`, so Sharpe/Sortino see
439    // the full price path even on Hold ticks.
440    last_equity: f64,
441    equity_curve: Vec<f64>,
442    period_returns: Vec<f64>,
443    // Cached marks per symbol (last close seen) so we can compute the
444    // total portfolio equity at any sample boundary even when only one
445    // symbol's price has just changed. `BTreeMap` for the same
446    // determinism reason as `positions`.
447    last_marks: BTreeMap<Symbol, f64>,
448    // Simulated reduce-only OCO brackets (SL stop-market + TP) per open
449    // position — the backtest counterpart of the live
450    // `ExecutionService::place_brackets`: recorded when a bracketed market
451    // entry fills, fired intra-candle, cleared when the position goes flat.
452    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    /// Record a protective OCO bracket for a freshly opened position.
493    fn set_bracket(&mut self, sym: &Symbol, bracket: Bracket) {
494        self.brackets.insert(sym.clone(), bracket);
495    }
496
497    /// Fire a resting protective bracket leg if this candle's range crosses
498    /// it, closing the whole position at the trigger price and emitting the
499    /// `TradeOutcome`. Pessimistic: when a single bar spans BOTH legs the
500    /// **stop** fills first. Stop-market reduce-only legs cross the spread, so
501    /// the fill is a taker and takes slippage; it fills at the trigger level
502    /// (the repo's fixed-level convention — gaps beyond it are not modelled).
503    /// Returns the synthetic [`Fill`] for the brain callback, or `None` if
504    /// nothing fired. Self-heals a stale bracket (flat or flipped position).
505    #[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        // Stale guard: the position is flat, or has flipped to the other side.
519        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    /// Record the latest close for a symbol, then sample portfolio
557    /// equity. The equity curve always grows by one sample per candle —
558    /// even on `Hold` ticks — so Sharpe/Sortino see the full price path.
559    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        // Drawdown bookkeeping.
564        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        // Per-period simple return on prior equity (skip the first
574        // sample — no prior period). Use prior equity to avoid divide-
575        // by-zero on a fully-drained account.
576        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    /// Total portfolio equity = realised cash + sum of unrealised PnL
586    /// across all symbols using their last-known marks.
587    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
609/// Validate a candle's OHLCV fields are usable: prices finite and
610/// strictly positive, volume finite and non-negative. Returns a
611/// human-readable reason on the first offending field.
612///
613/// OHLC *ordering* (`high >= low`, etc.) is intentionally not enforced —
614/// the goal is to keep `NaN`/`inf`/negative values out of the
615/// mark-to-market math, not to police exchange data quality.
616pub(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
633/// A decision resolved into a concrete fill request for the engine.
634struct ResolvedOrder {
635    side: Side,
636    qty: f64,
637    is_close: bool,
638    kind: OrderKind,
639    /// Limit price (quote currency) for resting kinds; `None` for market
640    /// and close orders.
641    limit_price: Option<f64>,
642}
643
644/// A simulated reduce-only OCO bracket attached to an open position — the
645/// backtest counterpart of the live `ExecutionService::place_brackets` SL
646/// (stop-market) + TP legs. Whichever leg the candle range crosses first
647/// closes the whole position (one-cancels-the-other).
648#[derive(Clone, Copy)]
649struct Bracket {
650    /// Stop-loss trigger price.
651    stop: f64,
652    /// Take-profit trigger price.
653    take_profit: f64,
654    /// `true` when protecting a long (stop below entry, TP above); `false`
655    /// for a short (stop above, TP below).
656    long: bool,
657}
658
659/// Feed each realised close into a symbol's risk gates — the session-PnL
660/// halt and the consecutive-loss circuit breaker — exactly as the live
661/// `FillRoutingService` does. Shared by the manual-fill and bracket-fill
662/// paths so the two can never diverge.
663fn 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
679/// Resolve a `Decision` into a [`ResolvedOrder`].
680fn 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
721/// Decide whether and at what reference price a resolved order fills on
722/// `candle`. Returns `(reference_price, is_taker)`; the caller applies
723/// slippage to taker fills. Returns `None` when a resting limit doesn't
724/// cross this candle (or a post-only would cross as taker).
725///
726/// - **Market / IOC / FOK and closes** fill immediately at the candle
727///   close as takers.
728/// - **Limit / PostOnly** rest: a buy fills when `low` trades down to the
729///   limit, a sell when `high` trades up to it. A limit already marketable
730///   at the candle open fills at the open as a taker (a post-only in that
731///   case is rejected); a non-marketable limit fills at its limit price as
732///   a maker. A missing limit falls back to the close, mirroring the live
733///   execution layer's event-price fallback.
734fn 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/// Apply a fill to the synthetic position. Emits one [`TradeOutcome`]
787/// per closed quantity (so a flip from +5 to -3 emits one close-5 trade).
788#[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    // Signed delta to the position quantity from this fill.
801    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    // The realised-PnL portion is whatever quantity *reduces* the
813    // existing position. Anything beyond that opens a new position in
814    // the opposite direction.
815    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        // Fee is apportioned by closing fraction so a single fill that
827        // both closes and reopens charges fees pro-rata to each side.
828        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        // The fee component charged to opening.
848        let fee_open = if qty > 0.0 {
849            fee * (opening_qty / qty)
850        } else {
851            0.0
852        };
853        state.cash -= fee_open;
854        // New entry price: if we were FLAT or fully closed first, this
855        // is the fresh entry; if we'd somehow added to an existing
856        // position (same-side fill), it's a weighted average. Brains
857        // don't pyramid in normal use since they emit one direction at
858        // a time, but handle it correctly anyway.
859        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    /// Brain that always emits the configured signal.
907    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        // Equity curve always seeds the initial cash, then one sample
988        // per candle.
989        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        // Buy on every candle. Position opens once; subsequent buys add
996        // to it (pyramiding). Test just runs to completion and asserts
997        // we accumulated *some* position and saw no trade close yet.
998        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        // Every candle emits Buy → orders_filled equals candle count
1009        // (sizer always returns ≥ 1 contract here).
1010        assert_eq!(result.orders_filled, 20);
1011        // No reducing fills yet → no completed trades.
1012        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        // Tie at t=3000 is broken by series-insertion order — AAA first.
1115        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        // Brain that goes long on AAA and short on BBB; FlipBrain takes
1122        // both sides simultaneously to verify per-symbol position state.
1123        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        // 5 AAA + 5 BBB = 10 orders (every candle, both symbols).
1158        assert_eq!(result.candles_processed, 10);
1159        assert_eq!(result.orders_filled, 10);
1160        // No closes — no completed trades yet.
1161        assert_eq!(result.trades.len(), 0);
1162        // Symbol label is the concatenated list.
1163        assert_eq!(result.symbol, "AAA,BBB");
1164    }
1165
1166    // ── Candle validation ───────────────────────────────────────────────
1167
1168    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        // Zero volume is legitimate (illiquid candle).
1183        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        // A NaN close passed straight through `with_candles` (bypassing
1221        // the CSV loader) must still be caught — otherwise it poisons
1222        // the equity curve and every metric silently.
1223        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        // Regression test for the HashMap-iteration-order determinism
1241        // bug: with two simultaneously-open positions, `equity_now` sums
1242        // unrealised PnL across the per-symbol map. Float addition isn't
1243        // associative, so a randomized map order would make the equity
1244        // curve differ run-to-run. `BTreeMap` fixes the order. Prices
1245        // are deliberately awkward so reordering *would* change the ULPs.
1246        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        // Bit-exact across runs — not just approximately equal.
1287        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}