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}