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//! # Perp funding
9//!
10//! With a [`FundingModel`](crate::FundingModel) configured (off by
11//! default), settlements in
12//! the window `(previous_candle_time, candle_time]` are booked against
13//! each symbol's position *as it stood entering the candle*, before any
14//! bracket fill or decision on that candle: `cashflow = −sign(qty) ×
15//! rate × |qty| × contract_value × last_mark` (positive rate → longs
16//! pay, shorts receive). Cashflows land in cash immediately (so the
17//! equity curve carries them) and accrue against the open position; when
18//! the position closes the accrual is attributed to the emitted
19//! [`TradeOutcome`]s pro-rata by closed quantity. Funding does **not**
20//! feed the session-PnL risk gate (parity with live, which gates on
21//! trade gross − fee); it does flow into the circuit breaker's win/loss
22//! classification via `TradeOutcome::net_pnl`.
23//!
24//! # Fill models
25//!
26//! [`BacktestConfig::fill_model`](crate::BacktestConfig::fill_model)
27//! selects the fill semantics. The default
28//! ([`FillModel::TakerAtClose`]) keeps the historical behaviour:
29//! everything fills (or is dropped) on the decision candle, and
30//! protective brackets fill at their fixed level. Opting in to
31//! [`FillModel::Resting`] gives honest resting-order semantics —
32//! non-marketable limits rest across candles (fill on cross at the
33//! limit, at the open on a gap through), stops fill at the level or
34//! worse, standalone stop/TP protective orders work without the other
35//! leg, and same-candle ambiguity resolves pessimistically. The complete
36//! convention list lives on [`FillModel`].
37//!
38//! # Multi-symbol replay
39//!
40//! Each candle is tagged with a symbol. The engine maintains independent
41//! [`Position`] state per symbol but a *single* shared cash balance. When
42//! [`Backtest::with_candles`] is used, every candle is assumed to be for
43//! the (single) symbol on the config. For multi-symbol runs use
44//! [`Backtest::with_symbol_candles`].
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48
49use chrono::{DateTime, TimeZone, Utc};
50use rustrade_core::{
51    Brain, Candle, Decision, Exchange, Fill, MarketDataEvent, OrderKind, Position, Side,
52    SignalType, SizeHint, Symbol,
53};
54use rustrade_risk::clock::ManualClock;
55use rustrade_risk::{CircuitBreaker, PositionSizer, SessionPnl};
56
57use crate::config::{BacktestConfig, FillModel};
58use crate::error::{Error, Result};
59use crate::fees::FeeModel;
60use crate::metrics::TradeOutcome;
61use crate::result::BacktestResult;
62use crate::slippage::SlippageModel;
63
64/// The replay engine itself. Configure via [`BacktestConfig`], attach a
65/// [`Brain`] and one or more candle series, then `.run().await` for the
66/// result.
67///
68/// # Example
69///
70/// ```no_run
71/// # use std::sync::Arc;
72/// use rustrade_backtest::{Backtest, BacktestConfig, load_csv};
73/// # async fn run(brain: Arc<dyn rustrade_core::Brain>) -> rustrade_backtest::Result<()> {
74/// let candles = load_csv("data/btcusdt-1m.csv")?;
75/// let result = Backtest::new(
76///     BacktestConfig::builder()
77///         .symbol("BTCUSDT")
78///         .initial_cash(10_000.0)
79///         .build()?,
80///     brain,
81/// )
82/// .with_candles(candles)
83/// .run()
84/// .await?;
85///
86/// println!("{}", result.summary());
87/// # Ok(())
88/// # }
89/// ```
90pub struct Backtest {
91    config: BacktestConfig,
92    brain: Arc<dyn Brain>,
93    /// Per-symbol candle series. Within each series candles are assumed
94    /// to be in chronological order; across series the engine merges
95    /// chronologically before replay.
96    series: Vec<(Symbol, Vec<Candle>)>,
97}
98
99impl Backtest {
100    /// Construct with a config + brain. The candle series is attached
101    /// separately via [`Self::with_candles`] / [`Self::with_symbol_candles`].
102    pub fn new(config: BacktestConfig, brain: Arc<dyn Brain>) -> Self {
103        Self {
104            config,
105            brain,
106            series: Vec::new(),
107        }
108    }
109
110    /// Feed a candle series for the (single) symbol on the config.
111    /// Convenience wrapper around [`Self::with_symbol_candles`].
112    ///
113    /// Panics if the config has more than one symbol — use
114    /// [`Self::with_symbol_candles`] for multi-symbol backtests.
115    pub fn with_candles(mut self, candles: Vec<Candle>) -> Self {
116        assert_eq!(
117            self.config.symbols.len(),
118            1,
119            "Backtest::with_candles requires a single-symbol config; \
120             this config has {} symbols. Use Backtest::with_symbol_candles instead.",
121            self.config.symbols.len()
122        );
123        let symbol = self.config.symbols[0].clone();
124        self.series = vec![(symbol, candles)];
125        self
126    }
127
128    /// Feed a candle series for a specific symbol. Call multiple times
129    /// for multi-symbol backtests; repeated calls for the same symbol
130    /// replace the previous series.
131    ///
132    /// The engine merges all series chronologically before replay, so
133    /// the brain sees the global event stream — not per-symbol blocks.
134    pub fn with_symbol_candles(mut self, symbol: impl Into<Symbol>, candles: Vec<Candle>) -> Self {
135        let symbol = symbol.into();
136        self.series.retain(|(s, _)| s != &symbol);
137        self.series.push((symbol, candles));
138        self
139    }
140
141    /// Run the backtest to completion. Returns the aggregated result.
142    pub async fn run(self) -> Result<BacktestResult> {
143        let exchange = Exchange::from("backtest");
144        let sizer = PositionSizer::new(self.config.sizing.clone());
145
146        let merged = merge_series(&self.series);
147        let candles_processed = merged.len();
148
149        // Reject non-finite / non-positive prices up front. A single
150        // `NaN` close would otherwise propagate through the equity curve
151        // and silently turn every downstream metric (Sharpe, Sortino,
152        // drawdown) into `NaN`. Fail loud instead.
153        for (symbol, candle) in &merged {
154            if let Err(why) = validate_candle(candle) {
155                return Err(Error::Data(format!(
156                    "{symbol} candle at t={}: {why}",
157                    candle.time
158                )));
159            }
160        }
161
162        let mut state = State::new(
163            self.config.initial_cash,
164            self.config.symbols.iter().cloned(),
165        );
166        let mut signals_emitted = 0usize;
167        let mut orders_filled = 0usize;
168        let mut orders_blocked = 0usize;
169        let mut trades: Vec<TradeOutcome> = Vec::new();
170
171        // Per-symbol risk gates (session-PnL halt + circuit breaker),
172        // mirroring the live execution path's gate sequence. Time is
173        // driven by candle timestamps through a shared `ManualClock`, so
174        // the UTC-midnight rollover and the breaker's window/cooldown run
175        // on replay time and the run stays deterministic.
176        let risk_clock = Arc::new(ManualClock::new(0));
177        let mut risk: BTreeMap<Symbol, SymbolRisk> = BTreeMap::new();
178        if self.config.session_pnl.is_some() || self.config.circuit_breaker.is_some() {
179            for s in &self.config.symbols {
180                risk.insert(
181                    s.clone(),
182                    SymbolRisk {
183                        session: self
184                            .config
185                            .session_pnl
186                            .clone()
187                            .map(|c| SessionPnl::with_clock(s.as_str(), c, risk_clock.clone())),
188                        breaker: self
189                            .config
190                            .circuit_breaker
191                            .clone()
192                            .map(|c| CircuitBreaker::with_clock(c, risk_clock.clone())),
193                    },
194                );
195            }
196        }
197
198        // Re-validate + normalise the funding schedule at run time.
199        // `BacktestConfig`'s fields are pub, so a schedule assigned after
200        // `build()` (or a config built via struct literal) would bypass
201        // the builder's validation — `settlements_between`'s binary
202        // search on an unsorted series silently drops or double-books
203        // settlements, corrupting funding PnL with no error. Sorting is
204        // the same normalisation `build()` applies; NaN rates and
205        // duplicate timestamps fail loud instead of poisoning results.
206        let funding = self
207            .config
208            .funding
209            .clone()
210            .validated()
211            .map_err(|why| Error::Config(format!("BacktestConfig.funding: {why}")))?;
212
213        // Per-symbol previous candle timestamp — the funding settlement
214        // window for a candle is `(prev_time, candle_time]`, matching the
215        // live paper ledger's entry-exclusive / exit-inclusive accrual.
216        let funding_on = funding.is_enabled();
217        let mut last_seen: BTreeMap<Symbol, i64> = BTreeMap::new();
218
219        // Honest resting-order semantics (see `FillModel::Resting`) —
220        // off by default, so legacy runs take none of the paths below.
221        let resting_mode = self.config.fill_model == FillModel::Resting;
222
223        for (symbol, candle) in &merged {
224            // Advance replay time and roll the risk state over before any
225            // decision on this candle — the live counterpart is the
226            // periodic `RiskSweepService` tick.
227            if !risk.is_empty() {
228                risk_clock.set(candle.time.max(0) as u64 / 1_000);
229                if let Some(r) = risk.get_mut(symbol) {
230                    r.tick();
231                }
232            }
233
234            // ── Perp funding settlements since this symbol's last candle ────
235            // Booked against the position as it stood entering the candle
236            // (so a position closed ON a settlement timestamp still pays it,
237            // and one opened exactly ON a settlement does not), at the
238            // last-known mark. Before a symbol's first candle the position
239            // is necessarily flat, so the missing window charges nothing.
240            if funding_on {
241                let prev = last_seen.insert(symbol.clone(), candle.time);
242                if let Some(prev_t) = prev {
243                    for (_, rate) in funding.settlements_between(prev_t, candle.time) {
244                        state.apply_funding(symbol, rate, self.config.contract_value);
245                    }
246                }
247            }
248
249            // ── Resting protective brackets fill first ──────────────────────
250            // Before this candle's decision, fire any SL/TP leg whose level the
251            // candle's range crosses — the live counterpart is the exchange
252            // filling the reduce-only OCO legs placed at entry. A stop-out loss
253            // still feeds the risk gates, same as a manual close.
254            {
255                let before = trades.len();
256                if let Some(fill) = state.check_bracket(
257                    symbol,
258                    candle,
259                    self.config.fees,
260                    self.config.slippage,
261                    self.config.contract_value,
262                    self.config.fill_model,
263                    candle_time(candle),
264                    &mut trades,
265                ) {
266                    if let Some(r) = risk.get_mut(symbol) {
267                        record_closes_into_risk(r, &trades[before..]);
268                    }
269                    orders_filled += 1;
270                    self.brain
271                        .on_fill(&fill)
272                        .await
273                        .map_err(|e| Error::Brain(e.to_string()))?;
274                }
275            }
276
277            // ── Resting entry orders fill second (FillModel::Resting) ───────
278            // A limit placed on an earlier candle fills when this candle's
279            // range reaches its level — at the limit price, or at this
280            // candle's open when it gaps through. Protective exits are
281            // evaluated first (above): reducing the old position before
282            // opening a new one is the pessimistic ordering when both are
283            // touchable in the same candle.
284            if resting_mode {
285                let before = trades.len();
286                let fills = state.check_resting_entry(
287                    symbol,
288                    candle,
289                    self.config.fees,
290                    self.config.slippage,
291                    self.config.contract_value,
292                    candle_time(candle),
293                    &mut trades,
294                );
295                if !fills.is_empty() {
296                    if let Some(r) = risk.get_mut(symbol) {
297                        record_closes_into_risk(r, &trades[before..]);
298                    }
299                    for fill in &fills {
300                        orders_filled += 1;
301                        self.brain
302                            .on_fill(fill)
303                            .await
304                            .map_err(|e| Error::Brain(e.to_string()))?;
305                    }
306                }
307            }
308
309            let event = MarketDataEvent::Candle {
310                exchange: exchange.clone(),
311                symbol: symbol.clone(),
312                candle: *candle,
313            };
314
315            // Brains see the live position at decision time — same as
316            // the live `ExecutionService` does. For symbols not in the
317            // config we still route the event through the brain (so
318            // multi-symbol brains can filter) but treat the position as
319            // FLAT and skip any orders.
320            let position = state.position(symbol).copied().unwrap_or(Position::FLAT);
321            let decision = self
322                .brain
323                .on_event(&event, &position)
324                .await
325                .map_err(|e| Error::Brain(e.to_string()))?;
326
327            let in_config = state.has_symbol(symbol);
328
329            if !in_config || matches!(decision.signal, SignalType::Hold) {
330                state.sample_step(
331                    symbol,
332                    candle.close,
333                    self.config.contract_value,
334                    candle.time,
335                );
336                continue;
337            }
338            signals_emitted += 1;
339
340            // ── Risk gates (parity with the live ExecutionService) ────
341            // Gate 1: session-PnL halt; gate 2: circuit breaker. Both
342            // block entries (`Buy`/`Sell`) after the signal is counted.
343            //
344            // Exit exemption (matches `ExecutionService`, rustrade #59): a
345            // genuine de-risking exit — a `Close` against a non-flat
346            // position — bypasses the halt/breaker checks. `resolve_order`
347            // turns exactly this case into a reduce-only fill sized to the
348            // current position, so it can only reduce or flatten, never
349            // open/increase/flip, and can never take on new risk under a
350            // halt. The predicate is intentionally tight: any `Buy`/`Sell`,
351            // and a `Close` with nothing to reduce (flat), stays gated. It
352            // reads the same `position` snapshot `resolve_order` sizes
353            // against, so the exemption decision and the order built can
354            // never diverge — the safe default is to gate unless the order
355            // is provably reducing.
356            let is_reduce_only_exit =
357                matches!(decision.signal, SignalType::Close) && position.close_side().is_some();
358            if !is_reduce_only_exit
359                && let Some(r) = risk.get(symbol)
360                && (r
361                    .session
362                    .as_ref()
363                    .is_some_and(SessionPnl::is_session_halted)
364                    || r.breaker.as_ref().is_some_and(CircuitBreaker::is_tripped))
365            {
366                orders_blocked += 1;
367                state.sample_step(
368                    symbol,
369                    candle.close,
370                    self.config.contract_value,
371                    candle.time,
372                );
373                continue;
374            }
375
376            // Resting mode: any non-`Hold` decision that reaches here (a
377            // gate-passing entry, or an exempt reduce-only exit) cancels
378            // the symbol's working entry order first (cancel-and-replace;
379            // the replacement may be nothing — e.g. a `Close` on a flat
380            // position just pulls the resting order). Gated-off entries
381            // above leave it resting.
382            if resting_mode {
383                state.cancel_resting(symbol);
384            }
385
386            // Translate the decision into a concrete fill request. For
387            // `Close` we use the existing position size. For Buy/Sell we
388            // size from the brain's hint just like ExecutionService, and
389            // carry the requested order kind + limit price.
390            let Some(resolved) = resolve_order(
391                &decision,
392                &position,
393                &sizer,
394                candle.close,
395                self.config.contract_value,
396            ) else {
397                state.sample_step(
398                    symbol,
399                    candle.close,
400                    self.config.contract_value,
401                    candle.time,
402                );
403                continue;
404            };
405            if resolved.qty <= 0.0 {
406                state.sample_step(
407                    symbol,
408                    candle.close,
409                    self.config.contract_value,
410                    candle.time,
411                );
412                continue;
413            }
414
415            // Decide the fill by order kind and fill model. Legacy
416            // (`TakerAtClose`): market / IOC / FOK and closes are immediate
417            // takers at the candle close; limit / post-only fill only if the
418            // decision candle's range crosses the limit (a post-only that
419            // would cross as taker is rejected) and are dropped otherwise —
420            // nothing rests across candles. `Resting`: non-marketable limits
421            // rest on the synthetic book and fill on later candles (see the
422            // resting-entry block above); partial fills still need an order
423            // book and remain out of scope.
424            let disposition = match self.config.fill_model {
425                FillModel::TakerAtClose => match resolve_fill(&resolved, candle) {
426                    // Legacy keeps its exact fill semantics (no limit cap):
427                    // this path is pinned bit-identical by regression tests.
428                    Some((price, is_taker)) => EntryDisposition::Immediate {
429                        price,
430                        is_taker,
431                        limit_cap: None,
432                    },
433                    None => EntryDisposition::Drop,
434                },
435                FillModel::Resting => resolve_entry_resting(&resolved, candle),
436            };
437            let (reference_price, is_taker, limit_cap) = match disposition {
438                EntryDisposition::Immediate {
439                    price,
440                    is_taker,
441                    limit_cap,
442                } => (price, is_taker, limit_cap),
443                EntryDisposition::Rest { limit } => {
444                    state.set_resting(
445                        symbol,
446                        RestingEntry {
447                            side: resolved.side,
448                            qty: resolved.qty,
449                            limit,
450                            stop: decision.stop_price.map(|p| p.value()),
451                            take_profit: decision.take_profit_price.map(|p| p.value()),
452                        },
453                    );
454                    state.sample_step(
455                        symbol,
456                        candle.close,
457                        self.config.contract_value,
458                        candle.time,
459                    );
460                    continue;
461                }
462                EntryDisposition::Drop => {
463                    state.sample_step(
464                        symbol,
465                        candle.close,
466                        self.config.contract_value,
467                        candle.time,
468                    );
469                    continue;
470                }
471            };
472            // Slippage applies to taker fills (crossing the spread); a
473            // resting maker fills at its limit price exactly. A marketable
474            // limit / IOC / FOK carries a `limit_cap`: slippage may move the
475            // fill toward the limit but never through it (buys cap at the
476            // limit as a ceiling, sells as a floor) — a real venue can't
477            // print a fill worse than the order's own limit.
478            let fill_price = if is_taker {
479                let slipped = self.config.slippage.apply(resolved.side, reference_price);
480                match limit_cap {
481                    Some(limit) => match resolved.side {
482                        Side::Buy => slipped.min(limit),
483                        Side::Sell => slipped.max(limit),
484                    },
485                    None => slipped,
486                }
487            } else {
488                reference_price
489            };
490            let fee = self.config.fees.fee_for(
491                fill_price,
492                resolved.qty * self.config.contract_value,
493                is_taker,
494            );
495
496            // Update position state. If this fill reduces or flips the
497            // position, emit one or more TradeOutcomes.
498            let trades_before = trades.len();
499            apply_fill(
500                &mut state,
501                symbol,
502                resolved.side,
503                resolved.qty,
504                fill_price,
505                fee,
506                self.config.contract_value,
507                candle_time(candle),
508                &mut trades,
509            );
510
511            // Feed each realised close into the risk gates — the live
512            // counterpart is `FillRoutingService`'s auto-PnL recording.
513            if let Some(r) = risk.get_mut(symbol) {
514                record_closes_into_risk(r, &trades[trades_before..]);
515            }
516
517            orders_filled += 1;
518
519            // Inform the brain of the (synthetic) fill — same callback
520            // the live `FillRoutingService` would invoke.
521            let fill = Fill {
522                symbol: symbol.clone(),
523                order_id: format!("bt-{orders_filled}"),
524                client_id: None,
525                side: resolved.side,
526                price: rustrade_core::Price(fill_price),
527                size: rustrade_core::Volume(resolved.qty),
528                fee,
529                fee_currency: "QUOTE".into(),
530                timestamp: candle_time(candle),
531            };
532            self.brain
533                .on_fill(&fill)
534                .await
535                .map_err(|e| Error::Brain(e.to_string()))?;
536
537            // Record a simulated protective bracket for a freshly opened
538            // entry (mirrors the live ExecutionService::place_brackets).
539            // Legacy mode requires BOTH legs (a full OCO); resting mode
540            // also honours standalone stop-only / TP-only protections. It
541            // is fired intra-candle on later candles; cleared when flat.
542            if !resolved.is_close {
543                let sl = decision.stop_price.map(|p| p.value());
544                let tp = decision.take_profit_price.map(|p| p.value());
545                let register = if resting_mode {
546                    sl.is_some() || tp.is_some()
547                } else {
548                    sl.is_some() && tp.is_some()
549                };
550                if register {
551                    let pos = state.position(symbol).copied().unwrap_or(Position::FLAT);
552                    if pos.qty != 0.0 {
553                        let long = pos.qty > 0.0;
554                        // Drop protective legs whose orientation is incoherent
555                        // for the surviving position (a reducing fill can carry
556                        // levels meant for a different intended position).
557                        let (stop, take_profit) = coherent_bracket_legs(long, fill_price, sl, tp);
558                        if stop.is_some() || take_profit.is_some() {
559                            state.set_bracket(
560                                symbol,
561                                Bracket {
562                                    stop,
563                                    take_profit,
564                                    long,
565                                },
566                            );
567                        }
568                    }
569                }
570            }
571
572            state.sample_step(
573                symbol,
574                candle.close,
575                self.config.contract_value,
576                candle.time,
577            );
578        }
579
580        let total_fees: f64 = trades.iter().map(|t| t.fee).sum();
581        let trades_net: f64 = trades.iter().map(|t| t.net_pnl()).sum();
582        let symbol_label = if self.config.symbols.len() == 1 {
583            self.config.symbols[0].as_str().to_string()
584        } else {
585            // Stable, deterministic label for multi-symbol runs.
586            let parts: Vec<&str> = self.config.symbols.iter().map(|s| s.as_str()).collect();
587            parts.join(",")
588        };
589
590        let returns = state.into_returns();
591        // Funding on positions still open at the end of the run is
592        // realised cash the trade ledger never sees — fold it into the
593        // headline PnL so `final_cash` agrees with the equity curve's
594        // cash component. Exactly `0.0` with funding off.
595        let net_pnl = trades_net + returns.open_funding;
596        Ok(BacktestResult {
597            symbol: symbol_label,
598            initial_cash: self.config.initial_cash,
599            final_cash: self.config.initial_cash + net_pnl,
600            net_pnl,
601            total_fees,
602            funding_received: returns.funding_received,
603            funding_paid: returns.funding_paid,
604            candles_processed,
605            signals_emitted,
606            orders_filled,
607            orders_blocked,
608            trades,
609            max_drawdown: returns.max_drawdown,
610            equity_curve: returns.equity,
611            equity_times: returns.equity_times,
612            period_returns: returns.period_returns,
613            risk_free_rate: self.config.risk_free_rate,
614            periods_per_year: self.config.periods_per_year,
615        })
616    }
617}
618
619/// Per-symbol risk gates for replay: the same primitives the live bot
620/// runs, time-driven by the shared replay clock.
621struct SymbolRisk {
622    session: Option<SessionPnl>,
623    breaker: Option<CircuitBreaker>,
624}
625
626impl SymbolRisk {
627    /// Roll both primitives forward to the current replay time.
628    fn tick(&mut self) {
629        if let Some(s) = &mut self.session {
630            s.tick();
631        }
632        if let Some(b) = &mut self.breaker {
633            b.tick();
634        }
635    }
636}
637
638// ── Series merging ──────────────────────────────────────────────────────
639
640/// Merge per-symbol candle series into a chronological `(symbol, candle)`
641/// stream. Stable for equal timestamps: ties preserve the *order the
642/// series were attached*, then the order candles appear within their
643/// series. This keeps multi-symbol runs deterministic even if two
644/// exchanges produce identical timestamps.
645fn merge_series(series: &[(Symbol, Vec<Candle>)]) -> Vec<(Symbol, Candle)> {
646    let total: usize = series.iter().map(|(_, c)| c.len()).sum();
647    let mut out: Vec<(Symbol, Candle, usize)> = Vec::with_capacity(total);
648    for (series_idx, (sym, candles)) in series.iter().enumerate() {
649        for c in candles {
650            out.push((sym.clone(), *c, series_idx));
651        }
652    }
653    // Sort by (time, series order) — stable for matching timestamps
654    // within the same series.
655    out.sort_by(|a, b| a.1.time.cmp(&b.1.time).then(a.2.cmp(&b.2)));
656    out.into_iter().map(|(s, c, _)| (s, c)).collect()
657}
658
659// ── State + helpers ─────────────────────────────────────────────────────
660
661/// Mutable in-loop state: per-symbol position, shared realised cash,
662/// equity HWM, drawdown, and the equity / per-period returns sample
663/// stream used by Sharpe / Sortino.
664struct State {
665    // `BTreeMap`, not `HashMap`: `equity_now` sums unrealised PnL by
666    // iterating this map, and float addition is not associative. A
667    // `HashMap`'s per-process-randomized iteration order would make a
668    // multi-symbol equity curve (and thus Sharpe/Sortino) differ in the
669    // last ULP between otherwise-identical runs, breaking the engine's
670    // determinism guarantee. `BTreeMap` iterates in sorted symbol order,
671    // so the summation order is fixed across runs.
672    positions: BTreeMap<Symbol, Position>,
673    cash: f64,
674    equity_hwm: f64,
675    max_drawdown: f64,
676    // Sampled once per candle in `sample_step`, so Sharpe/Sortino see
677    // the full price path even on Hold ticks.
678    last_equity: f64,
679    equity_curve: Vec<f64>,
680    period_returns: Vec<f64>,
681    // Cached marks per symbol (last close seen) so we can compute the
682    // total portfolio equity at any sample boundary even when only one
683    // symbol's price has just changed. `BTreeMap` for the same
684    // determinism reason as `positions`.
685    last_marks: BTreeMap<Symbol, f64>,
686    // Simulated reduce-only OCO brackets (SL stop-market + TP) per open
687    // position — the backtest counterpart of the live
688    // `ExecutionService::place_brackets`: recorded when a bracketed market
689    // entry fills, fired intra-candle, cleared when the position goes flat.
690    brackets: BTreeMap<Symbol, Bracket>,
691    // Resting entry orders (`FillModel::Resting` only) — at most one per
692    // symbol, GTC. Placed when a non-marketable limit / post-only entry
693    // reaches the synthetic book at a decision candle's close; filled by a
694    // later candle crossing the level; cancelled (replaced) by the next
695    // gate-passing non-`Hold` decision. `BTreeMap` for the same
696    // determinism reason as `positions`.
697    resting: BTreeMap<Symbol, RestingEntry>,
698    // Funding cashflow accrued against each *open* position (already in
699    // `cash`; tracked here so closes can attribute it to their
700    // `TradeOutcome`s). Cleared when the position goes flat or flips.
701    // `BTreeMap` for the same determinism reason as `positions`.
702    accrued_funding: BTreeMap<Symbol, f64>,
703    // Run-level funding totals: sums of the positive (received) and
704    // negative (paid, stored positive) settlement cashflows.
705    funding_received: f64,
706    funding_paid: f64,
707    // Candle timestamp of each equity sample; seeded alongside
708    // `equity_curve`'s initial-cash sample with the first candle's time.
709    equity_times: Vec<i64>,
710}
711
712struct ReturnsSummary {
713    max_drawdown: f64,
714    equity: Vec<f64>,
715    equity_times: Vec<i64>,
716    period_returns: Vec<f64>,
717    funding_received: f64,
718    funding_paid: f64,
719    /// Funding accrued on positions still open at the end of the run —
720    /// realised cash not attributed to any `TradeOutcome`.
721    open_funding: f64,
722}
723
724impl State {
725    fn new(initial_cash: f64, symbols: impl IntoIterator<Item = Symbol>) -> Self {
726        let mut positions = BTreeMap::new();
727        for s in symbols {
728            positions.insert(s, Position::FLAT);
729        }
730        Self {
731            positions,
732            cash: initial_cash,
733            equity_hwm: initial_cash,
734            max_drawdown: 0.0,
735            last_equity: initial_cash,
736            equity_curve: vec![initial_cash],
737            period_returns: Vec::new(),
738            last_marks: BTreeMap::new(),
739            brackets: BTreeMap::new(),
740            resting: BTreeMap::new(),
741            accrued_funding: BTreeMap::new(),
742            funding_received: 0.0,
743            funding_paid: 0.0,
744            equity_times: Vec::new(),
745        }
746    }
747
748    fn has_symbol(&self, sym: &Symbol) -> bool {
749        self.positions.contains_key(sym)
750    }
751
752    fn position(&self, sym: &Symbol) -> Option<&Position> {
753        self.positions.get(sym)
754    }
755
756    fn position_mut(&mut self, sym: &Symbol) -> &mut Position {
757        self.positions.entry(sym.clone()).or_insert(Position::FLAT)
758    }
759
760    /// Record a protective OCO bracket for a freshly opened position.
761    fn set_bracket(&mut self, sym: &Symbol, bracket: Bracket) {
762        self.brackets.insert(sym.clone(), bracket);
763    }
764
765    /// Place a resting entry order (`FillModel::Resting` only). At most
766    /// one per symbol — a new placement replaces the previous one.
767    fn set_resting(&mut self, sym: &Symbol, entry: RestingEntry) {
768        self.resting.insert(sym.clone(), entry);
769    }
770
771    /// Cancel the symbol's resting entry order, if any.
772    fn cancel_resting(&mut self, sym: &Symbol) {
773        self.resting.remove(sym);
774    }
775
776    /// Fire a resting protective bracket leg if this candle's range crosses
777    /// it, closing the whole position and emitting the `TradeOutcome`.
778    /// Pessimistic: when a single bar spans BOTH legs the **stop** fills
779    /// first. Fill-price conventions by mode:
780    ///
781    /// - [`FillModel::TakerAtClose`] (legacy): both legs fill at their
782    ///   fixed trigger level as takers with slippage — gaps beyond the
783    ///   level are not modelled.
784    /// - [`FillModel::Resting`]: the stop-market leg crosses the spread —
785    ///   taker with slippage, at the level *or worse* (a candle gapping
786    ///   through fills at its open). The take-profit leg is a resting
787    ///   limit — maker, no slippage, at the level or at the open when the
788    ///   candle gaps through it (price improvement).
789    ///
790    /// Returns the synthetic [`Fill`] for the brain callback, or `None` if
791    /// nothing fired. Self-heals a stale bracket (flat or flipped position).
792    #[allow(clippy::too_many_arguments)]
793    fn check_bracket(
794        &mut self,
795        sym: &Symbol,
796        candle: &Candle,
797        fees: FeeModel,
798        slippage: SlippageModel,
799        contract_value: f64,
800        fill_model: FillModel,
801        when: DateTime<Utc>,
802        trades: &mut Vec<TradeOutcome>,
803    ) -> Option<Fill> {
804        let bracket = self.brackets.get(sym).copied()?;
805        let pos = self.positions.get(sym).copied().unwrap_or(Position::FLAT);
806        // Stale guard: the position is flat, or has flipped to the other side.
807        if pos.qty == 0.0 || bracket.long != (pos.qty > 0.0) {
808            self.brackets.remove(sym);
809            return None;
810        }
811        let long = bracket.long;
812        let close_side = if long { Side::Sell } else { Side::Buy };
813        // Leg selection, stop first (pessimistic in both modes). For a
814        // long the stop sits below the market and the TP above; mirrored
815        // for a short. `crossed` is the honest gap-aware fill price;
816        // legacy mode overrides it with the fixed level below.
817        let stop_hit = bracket.stop.and_then(|level| {
818            let crossed = if long {
819                cross_down(level, candle)
820            } else {
821                cross_up(level, candle)
822            };
823            crossed.map(|px| (level, px, true))
824        });
825        let (level, crossed, is_stop) = stop_hit.or_else(|| {
826            bracket.take_profit.and_then(|level| {
827                let crossed = if long {
828                    cross_up(level, candle)
829                } else {
830                    cross_down(level, candle)
831                };
832                crossed.map(|px| (level, px, false))
833            })
834        })?;
835        let (fill_price, is_taker) = match fill_model {
836            // Legacy: fixed-level fill, taker + slippage on both legs.
837            FillModel::TakerAtClose => (slippage.apply(close_side, level), true),
838            FillModel::Resting => {
839                if is_stop {
840                    // Stop-market: crosses the spread at the trigger (or
841                    // at the open on a gap through) — taker + slippage.
842                    (slippage.apply(close_side, crossed), true)
843                } else {
844                    // Take-profit: a resting limit — maker, no slippage,
845                    // gap through → fill at the open (price improvement).
846                    (crossed, false)
847                }
848            }
849        };
850        let qty = pos.qty.abs();
851        let fee = fees.fee_for(fill_price, qty * contract_value, is_taker);
852        apply_fill(
853            self,
854            sym,
855            close_side,
856            qty,
857            fill_price,
858            fee,
859            contract_value,
860            when,
861            trades,
862        );
863        self.brackets.remove(sym);
864        Some(Fill {
865            symbol: sym.clone(),
866            order_id: "bt-bracket".to_string(),
867            client_id: None,
868            side: close_side,
869            price: rustrade_core::Price(fill_price),
870            size: rustrade_core::Volume(qty),
871            fee,
872            fee_currency: "QUOTE".into(),
873            timestamp: when,
874        })
875    }
876
877    /// Fill the symbol's resting entry order if this candle's range
878    /// reaches its level (`FillModel::Resting` only). The fill is a maker
879    /// — the order rested on the book — so it takes the maker fee rate
880    /// and no slippage, at the limit price on a cross or at this candle's
881    /// open when it gaps through the level.
882    ///
883    /// If the fill leaves a position open and the original decision
884    /// carried protective levels, the bracket is attached — and resolved
885    /// **conservatively** for this candle: the entry and its stop can
886    /// both lie inside the candle's range with the OHLC path unknown, so
887    /// the stop is assumed to have been hit *after* the entry filled
888    /// (immediate stop-out, the worse outcome). The take-profit gets no
889    /// such benefit of the doubt — its level may have printed *before*
890    /// the entry existed — so it only becomes eligible from the next
891    /// candle.
892    ///
893    /// Returns the emitted fills in order (entry, then a possible
894    /// same-candle stop-out); empty when nothing fired.
895    #[allow(clippy::too_many_arguments)]
896    fn check_resting_entry(
897        &mut self,
898        sym: &Symbol,
899        candle: &Candle,
900        fees: FeeModel,
901        slippage: SlippageModel,
902        contract_value: f64,
903        when: DateTime<Utc>,
904        trades: &mut Vec<TradeOutcome>,
905    ) -> Vec<Fill> {
906        let Some(entry) = self.resting.get(sym).copied() else {
907            return Vec::new();
908        };
909        let crossed = match entry.side {
910            Side::Buy => cross_down(entry.limit, candle),
911            Side::Sell => cross_up(entry.limit, candle),
912        };
913        let Some(entry_price) = crossed else {
914            return Vec::new();
915        };
916        self.resting.remove(sym);
917
918        let entry_fee = fees.fee_for(entry_price, entry.qty * contract_value, false);
919        apply_fill(
920            self,
921            sym,
922            entry.side,
923            entry.qty,
924            entry_price,
925            entry_fee,
926            contract_value,
927            when,
928            trades,
929        );
930        let mut fills = vec![Fill {
931            symbol: sym.clone(),
932            order_id: "bt-resting".to_string(),
933            client_id: None,
934            side: entry.side,
935            price: rustrade_core::Price(entry_price),
936            size: rustrade_core::Volume(entry.qty),
937            fee: entry_fee,
938            fee_currency: "QUOTE".into(),
939            timestamp: when,
940        }];
941
942        // Post-fill position. This fill may have reduced, flattened, or
943        // flipped a position that still carries a protective bracket from
944        // its own (now-dead) entry. A bracket left over from a position
945        // this fill flattened or flipped is stale — a real venue would
946        // have cancelled its reduce-only legs the moment the book went
947        // flat or crossed to the other side — so drop it before attaching
948        // (or not) this entry's own protection. Without this, a dead
949        // position's legs fire against the brand-new position on a later
950        // candle (the exact phantom order state the honest model removes).
951        let pos = self.positions.get(sym).copied().unwrap_or(Position::FLAT);
952        if let Some(existing) = self.brackets.get(sym).copied() {
953            let flat = pos.qty == 0.0;
954            let flipped = existing.long != (pos.qty > 0.0);
955            if flat || flipped {
956                self.brackets.remove(sym);
957            }
958        }
959
960        // Attach the decision's protective bracket to whatever position
961        // the fill leaves open (mirrors the immediate-fill path; partial
962        // stop-only / TP-only brackets are honoured in resting mode).
963        if entry.stop.is_none() && entry.take_profit.is_none() {
964            return fills;
965        }
966        if pos.qty == 0.0 {
967            return fills;
968        }
969        let long = pos.qty > 0.0;
970        // Drop protective legs whose orientation is incoherent for the
971        // surviving position (a reducing fill can carry levels meant for a
972        // different intended position); an incoherent leg would fire almost
973        // instantly against the survivor.
974        let (stop, take_profit) =
975            coherent_bracket_legs(long, entry_price, entry.stop, entry.take_profit);
976        if stop.is_none() && take_profit.is_none() {
977            return fills;
978        }
979        self.set_bracket(
980            sym,
981            Bracket {
982                stop,
983                take_profit,
984                long,
985            },
986        );
987
988        // Conservative same-candle stop check (see the method docs): the
989        // freshly attached stop may fire on the very candle that filled
990        // the entry; the TP may not.
991        let Some(stop) = stop else {
992            return fills;
993        };
994        let stop_crossed = if long {
995            cross_down(stop, candle)
996        } else {
997            cross_up(stop, candle)
998        };
999        let Some(stop_px) = stop_crossed else {
1000            return fills;
1001        };
1002        let close_side = if long { Side::Sell } else { Side::Buy };
1003        let fill_price = slippage.apply(close_side, stop_px);
1004        let qty = pos.qty.abs();
1005        let stop_fee = fees.fee_for(fill_price, qty * contract_value, true);
1006        apply_fill(
1007            self,
1008            sym,
1009            close_side,
1010            qty,
1011            fill_price,
1012            stop_fee,
1013            contract_value,
1014            when,
1015            trades,
1016        );
1017        self.brackets.remove(sym);
1018        fills.push(Fill {
1019            symbol: sym.clone(),
1020            order_id: "bt-bracket".to_string(),
1021            client_id: None,
1022            side: close_side,
1023            price: rustrade_core::Price(fill_price),
1024            size: rustrade_core::Volume(qty),
1025            fee: stop_fee,
1026            fee_currency: "QUOTE".into(),
1027            timestamp: when,
1028        });
1029        fills
1030    }
1031
1032    /// Book one funding settlement against a symbol's open position: the
1033    /// position pays `sign(qty) × rate × notional` (so a positive rate
1034    /// debits a long and credits a short), with notional valued at the
1035    /// last-known mark — the price as of the candle *before* the
1036    /// settlement, the backtest counterpart of the live ledger's
1037    /// entry-notional approximation. No-op on flat positions and on
1038    /// symbols with no mark yet (a position can't predate its first
1039    /// candle). The cashflow lands in `cash` immediately and accrues
1040    /// against the position for later trade attribution.
1041    fn apply_funding(&mut self, sym: &Symbol, rate: f64, contract_value: f64) {
1042        let Some(pos) = self.positions.get(sym) else {
1043            return;
1044        };
1045        if pos.qty == 0.0 {
1046            return;
1047        }
1048        let Some(&mark) = self.last_marks.get(sym) else {
1049            return;
1050        };
1051        let notional = pos.qty.abs() * contract_value * mark;
1052        let cashflow = -pos.qty.signum() * rate * notional;
1053        self.cash += cashflow;
1054        *self.accrued_funding.entry(sym.clone()).or_insert(0.0) += cashflow;
1055        if cashflow >= 0.0 {
1056            self.funding_received += cashflow;
1057        } else {
1058            self.funding_paid -= cashflow;
1059        }
1060    }
1061
1062    /// Record the latest close for a symbol, then sample portfolio
1063    /// equity. The equity curve always grows by one sample per candle —
1064    /// even on `Hold` ticks — so Sharpe/Sortino see the full price path.
1065    fn sample_step(&mut self, sym: &Symbol, close: f64, contract_value: f64, time: i64) {
1066        self.last_marks.insert(sym.clone(), close);
1067        let equity = self.equity_now(contract_value);
1068
1069        // Timestamp bookkeeping: the seed sample (initial cash, pushed in
1070        // `State::new` before any candle is known) borrows the first
1071        // candle's timestamp.
1072        if self.equity_times.is_empty() {
1073            self.equity_times.push(time);
1074        }
1075        self.equity_times.push(time);
1076
1077        // Drawdown bookkeeping.
1078        if equity > self.equity_hwm {
1079            self.equity_hwm = equity;
1080        }
1081        let dd = equity - self.equity_hwm;
1082        if dd < self.max_drawdown {
1083            self.max_drawdown = dd;
1084        }
1085
1086        self.equity_curve.push(equity);
1087        // Per-period simple return on prior equity (skip the first
1088        // sample — no prior period). Use prior equity to avoid divide-
1089        // by-zero on a fully-drained account.
1090        let prev = self.last_equity;
1091        if prev > 0.0 {
1092            self.period_returns.push((equity - prev) / prev);
1093        } else {
1094            self.period_returns.push(0.0);
1095        }
1096        self.last_equity = equity;
1097    }
1098
1099    /// Total portfolio equity = realised cash + sum of unrealised PnL
1100    /// across all symbols using their last-known marks.
1101    fn equity_now(&self, contract_value: f64) -> f64 {
1102        let mut equity = self.cash;
1103        for (sym, pos) in &self.positions {
1104            if let Some(entry) = pos.entry_price
1105                && let Some(mark) = self.last_marks.get(sym)
1106            {
1107                let pnl_per_unit = (mark - entry) * pos.qty.signum();
1108                equity += pnl_per_unit * pos.qty.abs() * contract_value;
1109            }
1110        }
1111        equity
1112    }
1113
1114    fn into_returns(self) -> ReturnsSummary {
1115        // `BTreeMap` iteration order keeps this sum deterministic.
1116        let open_funding: f64 = self.accrued_funding.values().sum();
1117        ReturnsSummary {
1118            max_drawdown: self.max_drawdown,
1119            equity: self.equity_curve,
1120            equity_times: self.equity_times,
1121            period_returns: self.period_returns,
1122            funding_received: self.funding_received,
1123            funding_paid: self.funding_paid,
1124            open_funding,
1125        }
1126    }
1127}
1128
1129/// Validate a candle's OHLCV fields are usable: prices finite and
1130/// strictly positive, volume finite and non-negative. Returns a
1131/// human-readable reason on the first offending field.
1132///
1133/// OHLC *ordering* (`high >= low`, etc.) is intentionally not enforced —
1134/// the goal is to keep `NaN`/`inf`/negative values out of the
1135/// mark-to-market math, not to police exchange data quality.
1136pub(crate) fn validate_candle(c: &Candle) -> std::result::Result<(), String> {
1137    for (name, v) in [
1138        ("open", c.open),
1139        ("high", c.high),
1140        ("low", c.low),
1141        ("close", c.close),
1142    ] {
1143        if !v.is_finite() || v <= 0.0 {
1144            return Err(format!("{name}={v} (prices must be finite and > 0)"));
1145        }
1146    }
1147    if !c.volume.is_finite() || c.volume < 0.0 {
1148        return Err(format!("volume={} (must be finite and >= 0)", c.volume));
1149    }
1150    Ok(())
1151}
1152
1153/// A decision resolved into a concrete fill request for the engine.
1154struct ResolvedOrder {
1155    side: Side,
1156    qty: f64,
1157    is_close: bool,
1158    kind: OrderKind,
1159    /// Limit price (quote currency) for resting kinds; `None` for market
1160    /// and close orders.
1161    limit_price: Option<f64>,
1162}
1163
1164/// A simulated reduce-only bracket attached to an open position — the
1165/// backtest counterpart of the live `ExecutionService::place_brackets` SL
1166/// (stop-market) + TP legs. Whichever leg the candle range crosses first
1167/// closes the whole position (one-cancels-the-other). Legacy mode always
1168/// records both legs; [`FillModel::Resting`] also honours standalone
1169/// stop-only / TP-only protections.
1170#[derive(Clone, Copy)]
1171struct Bracket {
1172    /// Stop-loss trigger price, if a stop leg exists.
1173    stop: Option<f64>,
1174    /// Take-profit trigger price, if a TP leg exists.
1175    take_profit: Option<f64>,
1176    /// `true` when protecting a long (stop below entry, TP above); `false`
1177    /// for a short (stop above, TP below).
1178    long: bool,
1179}
1180
1181/// A resting entry order on the synthetic book (`FillModel::Resting`
1182/// only): a non-marketable limit / post-only entry waiting for a later
1183/// candle to cross its level. Quantity is fixed at placement (sized at
1184/// the decision candle's close, like the immediate path). Carries the
1185/// decision's protective levels so the bracket attaches when the entry
1186/// eventually fills.
1187#[derive(Clone, Copy)]
1188struct RestingEntry {
1189    /// Direction of the entry.
1190    side: Side,
1191    /// Contracts to fill (whole-order fills only — no book depth).
1192    qty: f64,
1193    /// Resting limit level in quote currency.
1194    limit: f64,
1195    /// Stop-loss level to attach on fill, if the decision carried one.
1196    stop: Option<f64>,
1197    /// Take-profit level to attach on fill, if the decision carried one.
1198    take_profit: Option<f64>,
1199}
1200
1201// ── Honest level-cross fills ────────────────────────────────────────────
1202//
1203// The two primitives behind every resting fill. A level *below* the
1204// market path is a buy limit or a sell stop; a level *above* is a sell
1205// limit or a buy stop. Both fire on the same trigger their live
1206// counterparts use; the honest fill price is the level itself when the
1207// candle trades through it, or the candle's **open** when the candle
1208// already opens at/through the level (a gap — the level never traded, so
1209// the open was the first price the market offered). This one formula
1210// yields price *improvement* for gapped limit fills and *worse-than-level*
1211// fills for gapped stops, exactly as a real book would.
1212
1213/// Fill price for a resting order at `level` when the market crosses
1214/// **down** through it (buy limit below the market, or a long's sell
1215/// stop). `None` when the candle's range never reaches the level.
1216fn cross_down(level: f64, candle: &Candle) -> Option<f64> {
1217    (candle.low <= level).then(|| candle.open.min(level))
1218}
1219
1220/// Fill price for a resting order at `level` when the market crosses
1221/// **up** through it (sell limit above the market, or a short's buy
1222/// stop). `None` when the candle's range never reaches the level.
1223fn cross_up(level: f64, candle: &Candle) -> Option<f64> {
1224    (candle.high >= level).then(|| candle.open.max(level))
1225}
1226
1227/// Keep only the protective legs whose price sits on the correct side of
1228/// the fill for the surviving position's direction: a long's stop must be
1229/// below the entry and its take-profit above; a short is mirrored. A
1230/// reducing or flipping fill can leave a bracket built from levels meant
1231/// for a *different* intended position (e.g. a long that emits a partial
1232/// `Sell` carrying a stop above the market for the short it wanted); an
1233/// incoherent leg would fire almost immediately against the survivor. Such
1234/// legs are dropped. Returns the coherent `(stop, take_profit)`; either or
1235/// both may be `None`, in which case the caller registers no bracket.
1236fn coherent_bracket_legs(
1237    long: bool,
1238    entry_price: f64,
1239    stop: Option<f64>,
1240    take_profit: Option<f64>,
1241) -> (Option<f64>, Option<f64>) {
1242    let stop = stop.filter(|&s| {
1243        if long {
1244            s < entry_price
1245        } else {
1246            s > entry_price
1247        }
1248    });
1249    let take_profit = take_profit.filter(|&t| {
1250        if long {
1251            t > entry_price
1252        } else {
1253            t < entry_price
1254        }
1255    });
1256    (stop, take_profit)
1257}
1258
1259/// Feed each realised close into a symbol's risk gates — the session-PnL
1260/// halt and the consecutive-loss circuit breaker — exactly as the live
1261/// `FillRoutingService` does. Shared by the manual-fill and bracket-fill
1262/// paths so the two can never diverge.
1263fn record_closes_into_risk(r: &mut SymbolRisk, closes: &[TradeOutcome]) {
1264    for t in closes {
1265        if let Some(session) = &mut r.session {
1266            session.record_close(t.gross_pnl, t.fee);
1267        }
1268        if let Some(breaker) = &mut r.breaker {
1269            let net = t.net_pnl();
1270            if net > 0.0 {
1271                breaker.record_win();
1272            } else if net < 0.0 {
1273                breaker.record_loss();
1274            }
1275        }
1276    }
1277}
1278
1279/// Resolve a `Decision` into a [`ResolvedOrder`].
1280fn resolve_order(
1281    decision: &Decision,
1282    position: &Position,
1283    sizer: &PositionSizer,
1284    price: f64,
1285    contract_value: f64,
1286) -> Option<ResolvedOrder> {
1287    match decision.signal {
1288        SignalType::Hold => None,
1289        SignalType::Close => {
1290            let close_side = position.close_side()?;
1291            Some(ResolvedOrder {
1292                side: close_side,
1293                qty: position.qty.abs(),
1294                is_close: true,
1295                kind: OrderKind::Market,
1296                limit_price: None,
1297            })
1298        }
1299        SignalType::Buy | SignalType::Sell => {
1300            let side = if matches!(decision.signal, SignalType::Buy) {
1301                Side::Buy
1302            } else {
1303                Side::Sell
1304            };
1305            let contracts = size_from_hint(sizer, decision.size_hint, price, contract_value);
1306            if contracts == 0 {
1307                None
1308            } else {
1309                Some(ResolvedOrder {
1310                    side,
1311                    qty: contracts as f64,
1312                    is_close: false,
1313                    kind: decision.order_kind,
1314                    limit_price: decision.limit_price.map(|p| p.value()),
1315                })
1316            }
1317        }
1318    }
1319}
1320
1321/// What happens to a resolved entry order on its decision candle.
1322#[derive(Clone, Copy)]
1323enum EntryDisposition {
1324    /// Fill now at `price` (`is_taker` selects the fee rate and whether
1325    /// slippage applies). `limit_cap` bounds the post-slippage taker fill
1326    /// at the order's own limit — `Some(limit)` for a marketable limit /
1327    /// IOC / FOK (slippage can never print a fill through the limit a real
1328    /// venue would honour), `None` for a market order (no protective
1329    /// price, slippage applies uncapped).
1330    Immediate {
1331        price: f64,
1332        is_taker: bool,
1333        limit_cap: Option<f64>,
1334    },
1335    /// Rest on the synthetic book at `limit` (`FillModel::Resting` only).
1336    Rest { limit: f64 },
1337    /// Dropped without a fill: a marketable post-only, a non-marketable
1338    /// IOC/FOK (resting mode), or a limit the decision candle never
1339    /// crossed (legacy mode).
1340    Drop,
1341}
1342
1343/// Resolve an entry order under [`FillModel::Resting`]. The order reaches
1344/// the book at the decision candle's **close** — the market price at
1345/// decision time — so marketability is judged against the close and the
1346/// decision candle itself can never fill a resting order (its range
1347/// printed before the order existed; no lookahead).
1348///
1349/// - Market orders and closes: taker at the close, same as legacy.
1350/// - Limit: marketable (buy limit ≥ close / sell limit ≤ close) crosses
1351///   immediately at the close as taker; otherwise rests at the limit.
1352/// - PostOnly: rejected when marketable; otherwise rests (fills are
1353///   always maker).
1354/// - IOC / FOK: taker at the close when marketable, else cancelled. The
1355///   engine has no book depth, so both fill in full or not at all and
1356///   behave identically.
1357/// - A missing limit price falls back to the close (mirroring the live
1358///   execution layer's event-price fallback), which makes the order
1359///   immediately marketable.
1360fn resolve_entry_resting(resolved: &ResolvedOrder, candle: &Candle) -> EntryDisposition {
1361    // A market order (or a close) has no protective limit: it crosses at
1362    // the close as a taker and slippage applies uncapped.
1363    if resolved.is_close || matches!(resolved.kind, OrderKind::Market) {
1364        return EntryDisposition::Immediate {
1365            price: candle.close,
1366            is_taker: true,
1367            limit_cap: None,
1368        };
1369    }
1370    let limit = resolved.limit_price.unwrap_or(candle.close);
1371    let marketable = match resolved.side {
1372        Side::Buy => limit >= candle.close,
1373        Side::Sell => limit <= candle.close,
1374    };
1375    // A marketable limit / IOC / FOK crosses now at the close, but its own
1376    // limit caps the taker fill — slippage can push the reference toward
1377    // the limit yet never through it (a real venue fills at the limit or
1378    // rests the remainder; it can't print worse than the limit).
1379    let taker_capped = EntryDisposition::Immediate {
1380        price: candle.close,
1381        is_taker: true,
1382        limit_cap: Some(limit),
1383    };
1384    match resolved.kind {
1385        OrderKind::Ioc | OrderKind::Fok => {
1386            if marketable {
1387                taker_capped
1388            } else {
1389                EntryDisposition::Drop
1390            }
1391        }
1392        OrderKind::PostOnly => {
1393            if marketable {
1394                EntryDisposition::Drop
1395            } else {
1396                EntryDisposition::Rest { limit }
1397            }
1398        }
1399        OrderKind::Limit | OrderKind::Market => {
1400            if marketable {
1401                taker_capped
1402            } else {
1403                EntryDisposition::Rest { limit }
1404            }
1405        }
1406    }
1407}
1408
1409/// Decide whether and at what reference price a resolved order fills on
1410/// `candle` — the legacy [`FillModel::TakerAtClose`] path. Returns
1411/// `(reference_price, is_taker)`; the caller applies slippage to taker
1412/// fills. Returns `None` when a resting limit doesn't cross this candle
1413/// (or a post-only would cross as taker).
1414///
1415/// - **Market / IOC / FOK and closes** fill immediately at the candle
1416///   close as takers.
1417/// - **Limit / PostOnly** rest: a buy fills when `low` trades down to the
1418///   limit, a sell when `high` trades up to it. A limit already marketable
1419///   at the candle open fills at the open as a taker (a post-only in that
1420///   case is rejected); a non-marketable limit fills at its limit price as
1421///   a maker. A missing limit falls back to the close, mirroring the live
1422///   execution layer's event-price fallback.
1423fn resolve_fill(resolved: &ResolvedOrder, candle: &Candle) -> Option<(f64, bool)> {
1424    if resolved.is_close
1425        || matches!(
1426            resolved.kind,
1427            OrderKind::Market | OrderKind::Ioc | OrderKind::Fok
1428        )
1429    {
1430        return Some((candle.close, true));
1431    }
1432
1433    let limit = resolved.limit_price.unwrap_or(candle.close);
1434    let (fills, price, marketable) = match resolved.side {
1435        Side::Buy => (
1436            candle.low <= limit,
1437            limit.min(candle.open),
1438            limit >= candle.open,
1439        ),
1440        Side::Sell => (
1441            candle.high >= limit,
1442            limit.max(candle.open),
1443            limit <= candle.open,
1444        ),
1445    };
1446    if !fills {
1447        return None;
1448    }
1449    if matches!(resolved.kind, OrderKind::PostOnly) && marketable {
1450        return None;
1451    }
1452    Some((price, marketable))
1453}
1454
1455fn size_from_hint(sizer: &PositionSizer, hint: SizeHint, price: f64, contract_value: f64) -> u32 {
1456    match hint {
1457        SizeHint::Default => sizer.contracts(price, contract_value),
1458        SizeHint::MarginFraction(f) => {
1459            let f = f.clamp(0.0, 1.0);
1460            let margin = sizer.config().margin_per_trade * f;
1461            sizer.contracts_with_margin(margin, price, contract_value)
1462        }
1463        SizeHint::NotionalUsd(n) => {
1464            let leverage = sizer.config().leverage.max(1);
1465            let margin = n / f64::from(leverage);
1466            sizer.contracts_with_margin(margin, price, contract_value)
1467        }
1468        SizeHint::Quantity(q) => {
1469            let raw = q.value().max(0.0).floor() as u32;
1470            raw.min(sizer.config().max_contracts)
1471        }
1472    }
1473}
1474
1475/// Apply a fill to the synthetic position. Emits one [`TradeOutcome`]
1476/// per closed quantity (so a flip from +5 to -3 emits one close-5 trade).
1477#[allow(clippy::too_many_arguments)]
1478fn apply_fill(
1479    state: &mut State,
1480    symbol: &Symbol,
1481    side: Side,
1482    qty: f64,
1483    fill_price: f64,
1484    fee: f64,
1485    contract_value: f64,
1486    when: DateTime<Utc>,
1487    trades: &mut Vec<TradeOutcome>,
1488) {
1489    // Signed delta to the position quantity from this fill.
1490    let signed_qty = match side {
1491        Side::Buy => qty,
1492        Side::Sell => -qty,
1493    };
1494
1495    let (old_qty, old_entry) = {
1496        let p = state.position_mut(symbol);
1497        (p.qty, p.entry_price)
1498    };
1499    let new_qty = old_qty + signed_qty;
1500
1501    // The realised-PnL portion is whatever quantity *reduces* the
1502    // existing position. Anything beyond that opens a new position in
1503    // the opposite direction.
1504    let closing_qty = if old_qty.signum() != signed_qty.signum() && old_qty != 0.0 {
1505        old_qty.abs().min(qty)
1506    } else {
1507        0.0
1508    };
1509    let opening_qty = qty - closing_qty;
1510
1511    if closing_qty > 0.0 {
1512        let entry = old_entry.unwrap_or(fill_price);
1513        let direction = old_qty.signum();
1514        let gross = (fill_price - entry) * direction * closing_qty * contract_value;
1515        // Fee is apportioned by closing fraction so a single fill that
1516        // both closes and reopens charges fees pro-rata to each side.
1517        let fee_share = if qty > 0.0 {
1518            fee * (closing_qty / qty)
1519        } else {
1520            0.0
1521        };
1522        // Attribute the position's accrued funding to this close,
1523        // pro-rata by closed quantity. The cash itself was booked at
1524        // each settlement — this only moves the attribution onto the
1525        // trade ledger. A full close (or flip) takes the whole accrual,
1526        // so no float-division dust is left behind.
1527        let accrued = state.accrued_funding.get(symbol).copied().unwrap_or(0.0);
1528        let closes_all = closing_qty >= old_qty.abs();
1529        let funding_share = if closes_all {
1530            accrued
1531        } else {
1532            accrued * (closing_qty / old_qty.abs())
1533        };
1534        if closes_all {
1535            state.accrued_funding.remove(symbol);
1536        } else if let Some(a) = state.accrued_funding.get_mut(symbol) {
1537            *a -= funding_share;
1538        }
1539        trades.push(TradeOutcome {
1540            symbol: symbol.as_str().to_string(),
1541            close_side: side,
1542            qty: closing_qty,
1543            entry_price: entry,
1544            exit_price: fill_price,
1545            gross_pnl: gross,
1546            fee: fee_share,
1547            funding: funding_share,
1548            closed_at: when,
1549        });
1550        state.cash += gross - fee_share;
1551    }
1552
1553    let new_position = if opening_qty > 0.0 {
1554        // The fee component charged to opening.
1555        let fee_open = if qty > 0.0 {
1556            fee * (opening_qty / qty)
1557        } else {
1558            0.0
1559        };
1560        state.cash -= fee_open;
1561        // New entry price: if we were FLAT or fully closed first, this
1562        // is the fresh entry; if we'd somehow added to an existing
1563        // position (same-side fill), it's a weighted average. Brains
1564        // don't pyramid in normal use since they emit one direction at
1565        // a time, but handle it correctly anyway.
1566        let new_position_qty_after_close = old_qty + side_sign(side) * closing_qty;
1567        let post_open_qty = new_position_qty_after_close + side_sign(side) * opening_qty;
1568        let entry = if new_position_qty_after_close == 0.0 {
1569            fill_price
1570        } else {
1571            let prev_entry = old_entry.unwrap_or(fill_price);
1572            let prev_notional = prev_entry * new_position_qty_after_close.abs();
1573            let new_notional = fill_price * opening_qty;
1574            (prev_notional + new_notional) / post_open_qty.abs()
1575        };
1576        Position {
1577            qty: post_open_qty,
1578            entry_price: Some(entry),
1579            unrealised_pnl: 0.0,
1580        }
1581    } else if new_qty == 0.0 {
1582        Position::FLAT
1583    } else {
1584        Position {
1585            qty: new_qty,
1586            entry_price: old_entry,
1587            unrealised_pnl: 0.0,
1588        }
1589    };
1590    *state.position_mut(symbol) = new_position;
1591}
1592
1593fn side_sign(side: Side) -> f64 {
1594    match side {
1595        Side::Buy => 1.0,
1596        Side::Sell => -1.0,
1597    }
1598}
1599
1600fn candle_time(c: &Candle) -> DateTime<Utc> {
1601    Utc.timestamp_millis_opt(c.time)
1602        .single()
1603        .unwrap_or_else(Utc::now)
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609    use async_trait::async_trait;
1610    use rustrade_core::{BrainHealth, Decision, MarketDataEvent, Position, Result as CoreResult};
1611    use rustrade_risk::SizingConfig;
1612
1613    /// Brain that always emits the configured signal.
1614    struct FixedBrain {
1615        signal: SignalType,
1616    }
1617    #[async_trait]
1618    impl Brain for FixedBrain {
1619        fn name(&self) -> &str {
1620            "fixed"
1621        }
1622        async fn on_event(&self, _e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
1623            Ok(match self.signal {
1624                SignalType::Hold => Decision::hold(),
1625                SignalType::Buy => Decision::buy(1.0),
1626                SignalType::Sell => Decision::sell(1.0),
1627                SignalType::Close => Decision::close(),
1628            })
1629        }
1630        async fn health(&self) -> BrainHealth {
1631            BrainHealth::ok()
1632        }
1633    }
1634
1635    fn flat_series(n: usize, price: f64) -> Vec<Candle> {
1636        (0..n)
1637            .map(|i| Candle {
1638                time: i as i64 * 60_000,
1639                open: price,
1640                high: price,
1641                low: price,
1642                close: price,
1643                volume: 1.0,
1644            })
1645            .collect()
1646    }
1647
1648    fn ramp_series(n: usize, start: f64, step: f64) -> Vec<Candle> {
1649        (0..n)
1650            .map(|i| {
1651                let p = start + step * i as f64;
1652                Candle {
1653                    time: i as i64 * 60_000,
1654                    open: p,
1655                    high: p,
1656                    low: p,
1657                    close: p,
1658                    volume: 1.0,
1659                }
1660            })
1661            .collect()
1662    }
1663
1664    fn cfg() -> BacktestConfig {
1665        BacktestConfig::builder()
1666            .symbol("BTCUSDT")
1667            .initial_cash(10_000.0)
1668            .sizing(SizingConfig {
1669                margin_per_trade: 1_000.0,
1670                leverage: 1,
1671                max_contracts: 100,
1672            })
1673            .build()
1674            .unwrap()
1675    }
1676
1677    #[tokio::test]
1678    async fn hold_brain_produces_no_trades() {
1679        let result = Backtest::new(
1680            cfg(),
1681            Arc::new(FixedBrain {
1682                signal: SignalType::Hold,
1683            }),
1684        )
1685        .with_candles(flat_series(50, 100.0))
1686        .run()
1687        .await
1688        .unwrap();
1689        assert_eq!(result.signals_emitted, 0);
1690        assert_eq!(result.orders_filled, 0);
1691        assert_eq!(result.trades.len(), 0);
1692        assert_eq!(result.net_pnl, 0.0);
1693        assert_eq!(result.candles_processed, 50);
1694        // Equity curve always seeds the initial cash, then one sample
1695        // per candle.
1696        assert_eq!(result.equity_curve.len(), 51);
1697        assert_eq!(result.period_returns.len(), 50);
1698    }
1699
1700    #[tokio::test]
1701    async fn buy_then_close_realises_pnl_on_uptrend() {
1702        // Buy on every candle. Position opens once; subsequent buys add
1703        // to it (pyramiding). Test just runs to completion and asserts
1704        // we accumulated *some* position and saw no trade close yet.
1705        let result = Backtest::new(
1706            cfg(),
1707            Arc::new(FixedBrain {
1708                signal: SignalType::Buy,
1709            }),
1710        )
1711        .with_candles(ramp_series(20, 100.0, 1.0))
1712        .run()
1713        .await
1714        .unwrap();
1715        // Every candle emits Buy → orders_filled equals candle count
1716        // (sizer always returns ≥ 1 contract here).
1717        assert_eq!(result.orders_filled, 20);
1718        // No reducing fills yet → no completed trades.
1719        assert_eq!(result.trades.len(), 0);
1720        assert_eq!(result.net_pnl, 0.0);
1721    }
1722
1723    #[tokio::test]
1724    async fn determinism_two_runs_same_inputs() {
1725        let series = ramp_series(30, 100.0, 0.5);
1726        let r1 = Backtest::new(
1727            cfg(),
1728            Arc::new(FixedBrain {
1729                signal: SignalType::Buy,
1730            }),
1731        )
1732        .with_candles(series.clone())
1733        .run()
1734        .await
1735        .unwrap();
1736        let r2 = Backtest::new(
1737            cfg(),
1738            Arc::new(FixedBrain {
1739                signal: SignalType::Buy,
1740            }),
1741        )
1742        .with_candles(series)
1743        .run()
1744        .await
1745        .unwrap();
1746        assert_eq!(r1.candles_processed, r2.candles_processed);
1747        assert_eq!(r1.signals_emitted, r2.signals_emitted);
1748        assert_eq!(r1.orders_filled, r2.orders_filled);
1749        assert_eq!(r1.trades.len(), r2.trades.len());
1750        assert!((r1.net_pnl - r2.net_pnl).abs() < 1e-12);
1751        assert_eq!(r1.equity_curve, r2.equity_curve);
1752    }
1753
1754    #[tokio::test]
1755    async fn close_against_flat_is_noop() {
1756        let result = Backtest::new(
1757            cfg(),
1758            Arc::new(FixedBrain {
1759                signal: SignalType::Close,
1760            }),
1761        )
1762        .with_candles(flat_series(10, 100.0))
1763        .run()
1764        .await
1765        .unwrap();
1766        assert_eq!(result.orders_filled, 0);
1767        assert_eq!(result.trades.len(), 0);
1768    }
1769
1770    #[test]
1771    fn merge_series_interleaves_by_timestamp() {
1772        let s1 = Symbol::from("AAA");
1773        let s2 = Symbol::from("BBB");
1774        let series = vec![
1775            (
1776                s1.clone(),
1777                vec![
1778                    Candle {
1779                        time: 1000,
1780                        open: 1.0,
1781                        high: 1.0,
1782                        low: 1.0,
1783                        close: 1.0,
1784                        volume: 0.0,
1785                    },
1786                    Candle {
1787                        time: 3000,
1788                        open: 1.0,
1789                        high: 1.0,
1790                        low: 1.0,
1791                        close: 1.0,
1792                        volume: 0.0,
1793                    },
1794                ],
1795            ),
1796            (
1797                s2.clone(),
1798                vec![
1799                    Candle {
1800                        time: 2000,
1801                        open: 2.0,
1802                        high: 2.0,
1803                        low: 2.0,
1804                        close: 2.0,
1805                        volume: 0.0,
1806                    },
1807                    Candle {
1808                        time: 3000,
1809                        open: 2.0,
1810                        high: 2.0,
1811                        low: 2.0,
1812                        close: 2.0,
1813                        volume: 0.0,
1814                    },
1815                ],
1816            ),
1817        ];
1818        let merged = merge_series(&series);
1819        let times: Vec<i64> = merged.iter().map(|(_, c)| c.time).collect();
1820        assert_eq!(times, vec![1000, 2000, 3000, 3000]);
1821        // Tie at t=3000 is broken by series-insertion order — AAA first.
1822        assert_eq!(merged[2].0, s1);
1823        assert_eq!(merged[3].0, s2);
1824    }
1825
1826    #[tokio::test]
1827    async fn multi_symbol_routes_to_each_symbol_state() {
1828        // Brain that goes long on AAA and short on BBB; FlipBrain takes
1829        // both sides simultaneously to verify per-symbol position state.
1830        struct SymBrain;
1831        #[async_trait]
1832        impl Brain for SymBrain {
1833            fn name(&self) -> &str {
1834                "sym"
1835            }
1836            async fn on_event(&self, e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
1837                match e.symbol().as_str() {
1838                    "AAA" => Ok(Decision::buy(1.0)),
1839                    "BBB" => Ok(Decision::sell(1.0)),
1840                    _ => Ok(Decision::hold()),
1841                }
1842            }
1843            async fn health(&self) -> BrainHealth {
1844                BrainHealth::ok()
1845            }
1846        }
1847
1848        let cfg = BacktestConfig::builder()
1849            .symbols(["AAA", "BBB"])
1850            .initial_cash(100_000.0)
1851            .sizing(SizingConfig {
1852                margin_per_trade: 1_000.0,
1853                leverage: 1,
1854                max_contracts: 100,
1855            })
1856            .build()
1857            .unwrap();
1858        let result = Backtest::new(cfg, Arc::new(SymBrain))
1859            .with_symbol_candles("AAA", flat_series(5, 100.0))
1860            .with_symbol_candles("BBB", flat_series(5, 200.0))
1861            .run()
1862            .await
1863            .unwrap();
1864        // 5 AAA + 5 BBB = 10 orders (every candle, both symbols).
1865        assert_eq!(result.candles_processed, 10);
1866        assert_eq!(result.orders_filled, 10);
1867        // No closes — no completed trades yet.
1868        assert_eq!(result.trades.len(), 0);
1869        // Symbol label is the concatenated list.
1870        assert_eq!(result.symbol, "AAA,BBB");
1871    }
1872
1873    // ── Honest level-cross helpers ──────────────────────────────────────
1874
1875    fn range_candle(open: f64, high: f64, low: f64, close: f64) -> Candle {
1876        Candle {
1877            time: 0,
1878            open,
1879            high,
1880            low,
1881            close,
1882            volume: 1.0,
1883        }
1884    }
1885
1886    #[test]
1887    fn cross_down_fills_at_level_on_intrabar_cross() {
1888        // Opens above the level, trades down through it → the level traded.
1889        let c = range_candle(100.0, 101.0, 94.0, 96.0);
1890        assert_eq!(cross_down(95.0, &c), Some(95.0));
1891    }
1892
1893    #[test]
1894    fn cross_down_fills_at_open_on_gap_through() {
1895        // Opens already below the level → the level never traded; the open
1896        // was the first price the market offered.
1897        let c = range_candle(90.0, 92.0, 89.0, 91.0);
1898        assert_eq!(cross_down(95.0, &c), Some(90.0));
1899    }
1900
1901    #[test]
1902    fn cross_down_none_when_low_never_reaches_level() {
1903        let c = range_candle(100.0, 101.0, 96.0, 97.0);
1904        assert_eq!(cross_down(95.0, &c), None);
1905    }
1906
1907    #[test]
1908    fn cross_up_fills_at_level_on_intrabar_cross() {
1909        let c = range_candle(100.0, 106.0, 99.0, 104.0);
1910        assert_eq!(cross_up(105.0, &c), Some(105.0));
1911    }
1912
1913    #[test]
1914    fn cross_up_fills_at_open_on_gap_through() {
1915        let c = range_candle(110.0, 112.0, 108.0, 111.0);
1916        assert_eq!(cross_up(105.0, &c), Some(110.0));
1917    }
1918
1919    #[test]
1920    fn cross_up_none_when_high_never_reaches_level() {
1921        let c = range_candle(100.0, 104.0, 99.0, 103.0);
1922        assert_eq!(cross_up(105.0, &c), None);
1923    }
1924
1925    #[test]
1926    fn cross_fires_on_exact_touch() {
1927        // Boundary: a low/high exactly ON the level counts as a cross and
1928        // fills at the level (open is on the far side, so no gap).
1929        let c = range_candle(100.0, 105.0, 95.0, 100.0);
1930        assert_eq!(cross_down(95.0, &c), Some(95.0));
1931        assert_eq!(cross_up(105.0, &c), Some(105.0));
1932    }
1933
1934    // ── Candle validation ───────────────────────────────────────────────
1935
1936    fn good_candle() -> Candle {
1937        Candle {
1938            time: 0,
1939            open: 1.0,
1940            high: 1.0,
1941            low: 1.0,
1942            close: 1.0,
1943            volume: 1.0,
1944        }
1945    }
1946
1947    #[test]
1948    fn validate_candle_accepts_finite_positive() {
1949        assert!(validate_candle(&good_candle()).is_ok());
1950        // Zero volume is legitimate (illiquid candle).
1951        let c = Candle {
1952            volume: 0.0,
1953            ..good_candle()
1954        };
1955        assert!(validate_candle(&c).is_ok());
1956    }
1957
1958    #[test]
1959    fn validate_candle_rejects_non_finite_and_non_positive_prices() {
1960        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -1.0] {
1961            let c = Candle {
1962                close: bad,
1963                ..good_candle()
1964            };
1965            assert!(
1966                validate_candle(&c).is_err(),
1967                "close={bad} should be rejected"
1968            );
1969        }
1970    }
1971
1972    #[test]
1973    fn validate_candle_rejects_negative_or_nan_volume() {
1974        for bad in [-1.0, f64::NAN, f64::INFINITY] {
1975            let c = Candle {
1976                volume: bad,
1977                ..good_candle()
1978            };
1979            assert!(
1980                validate_candle(&c).is_err(),
1981                "volume={bad} should be rejected"
1982            );
1983        }
1984    }
1985
1986    #[tokio::test]
1987    async fn run_rejects_non_finite_candle() {
1988        // A NaN close passed straight through `with_candles` (bypassing
1989        // the CSV loader) must still be caught — otherwise it poisons
1990        // the equity curve and every metric silently.
1991        let mut series = flat_series(5, 100.0);
1992        series[2].close = f64::NAN;
1993        let err = Backtest::new(
1994            cfg(),
1995            Arc::new(FixedBrain {
1996                signal: SignalType::Hold,
1997            }),
1998        )
1999        .with_candles(series)
2000        .run()
2001        .await
2002        .unwrap_err();
2003        assert!(matches!(err, Error::Data(_)), "got {err:?}");
2004    }
2005
2006    #[tokio::test]
2007    async fn multi_symbol_equity_curve_deterministic_across_runs() {
2008        // Regression test for the HashMap-iteration-order determinism
2009        // bug: with two simultaneously-open positions, `equity_now` sums
2010        // unrealised PnL across the per-symbol map. Float addition isn't
2011        // associative, so a randomized map order would make the equity
2012        // curve differ run-to-run. `BTreeMap` fixes the order. Prices
2013        // are deliberately awkward so reordering *would* change the ULPs.
2014        struct DualLong;
2015        #[async_trait]
2016        impl Brain for DualLong {
2017            fn name(&self) -> &str {
2018                "dual-long"
2019            }
2020            async fn on_event(&self, e: &MarketDataEvent, p: &Position) -> CoreResult<Decision> {
2021                if p.qty == 0.0 && matches!(e, MarketDataEvent::Candle { .. }) {
2022                    Ok(Decision::buy(1.0))
2023                } else {
2024                    Ok(Decision::hold())
2025                }
2026            }
2027            async fn health(&self) -> BrainHealth {
2028                BrainHealth::ok()
2029            }
2030        }
2031
2032        let run = || async {
2033            let cfg = BacktestConfig::builder()
2034                .symbols(["AAA", "BBB", "CCC"])
2035                .initial_cash(1_000_000.0)
2036                .sizing(SizingConfig {
2037                    margin_per_trade: 1_000.0,
2038                    leverage: 1,
2039                    max_contracts: 100,
2040                })
2041                .build()
2042                .unwrap();
2043            Backtest::new(cfg, Arc::new(DualLong))
2044                .with_symbol_candles("AAA", ramp_series(40, 100.13, 0.37))
2045                .with_symbol_candles("BBB", ramp_series(40, 250.07, -0.19))
2046                .with_symbol_candles("CCC", ramp_series(40, 33.31, 0.53))
2047                .run()
2048                .await
2049                .unwrap()
2050        };
2051
2052        let r1 = run().await;
2053        let r2 = run().await;
2054        // Bit-exact across runs — not just approximately equal.
2055        assert_eq!(r1.equity_curve, r2.equity_curve);
2056        assert_eq!(r1.period_returns, r2.period_returns);
2057        assert_eq!(r1.net_pnl.to_bits(), r2.net_pnl.to_bits());
2058        assert_eq!(r1.max_drawdown.to_bits(), r2.max_drawdown.to_bits());
2059    }
2060}