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}