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