Skip to main content

wickra_backtest_core/
engine.rs

1//! The event-driven backtest loop.
2//!
3//! Look-ahead bias is structurally prevented by default: signal-driven orders
4//! are decided on a bar's **close** and fill on the **next bar's open**. An
5//! opt-in `fill_timing: "close"` instead fills market orders on the signalling
6//! bar's own close (close-to-close, deliberately optimistic). Stop-loss and
7//! take-profit are price levels checked **intrabar** against each bar's OHLC and
8//! fill at the level (conservative: the stop is assumed hit before the target
9//! when a bar's range brackets both). Equity is marked to market at every close.
10//!
11//! Supports long and short positions; market, limit and stop entry orders (a
12//! limit/stop rests at a percent offset from the signal close and fills when a
13//! later bar reaches it, otherwise it keeps working); maker/taker fees (resting
14//! limit fills pay maker, market/stop fills pay taker) and fixed-bps, order-book-spread or
15//! volume-impact slippage; and leverage / position sizing (fixed fraction / cash / quantity,
16//! risk-per-trade and vol-target, capped by `max_leverage` and
17//! `max_position_pct`; without `max_leverage` the cap is 1x equity — no leverage
18//! by default). Execution latency (`latency_bars`) delays every fill, and
19//! volume-participation partial fills (`partial_fills` + `max_participation`)
20//! cap an entry to a fraction of the bar's volume. Perpetual funding
21//! (`costs.funding`) is charged each bar to an open position from the
22//! derivatives feed, and a leveraged position is liquidated intrabar at its
23//! bankruptcy price when `risk.liquidation` is set.
24
25use std::borrow::Cow;
26use std::collections::BTreeMap;
27use std::fmt;
28use std::sync::Arc;
29
30use wickra_core::{
31    CrossSection as CoreCrossSection, DerivativesTick as CoreDerivativesTick,
32    OrderBook as CoreOrderBook, Trade as CoreTrade,
33};
34
35use crate::data::{Candle, CrossSection, DerivativesTick, OrderBook, TradePrint};
36use crate::error::{BacktestError, Result};
37use crate::metrics;
38use crate::portfolio::Portfolio;
39use crate::registry::{self, BarInput, EvalIndicator};
40use crate::report::{BacktestReport, EquityPoint, REPORT_SCHEMA_VERSION};
41use crate::rules::{condition_lookback, eval_condition, BarRow, RuleState};
42use crate::spec::{Execution, FillTiming, OrderType, Risk, Sizing, Slippage, StrategySpec};
43
44/// Default starting capital for the runner.
45pub const DEFAULT_CAPITAL: f64 = 10_000.0;
46
47#[derive(Debug, Clone, Copy)]
48enum Side {
49    Long,
50    Short,
51}
52
53/// A resting limit or stop trigger.
54#[derive(Debug, Clone, Copy)]
55enum LevelKind {
56    Limit,
57    Stop,
58    /// Stop-limit: the trigger is the stop, and touching it activates a limit
59    /// order at `limit`. Carrying the limit here rather than beside the trigger
60    /// keeps the resting order one value, the way the other two kinds are.
61    StopLimit {
62        limit: f64,
63    },
64}
65
66/// What a working order does once it fills.
67#[derive(Debug)]
68enum Action {
69    /// An entry. `trigger` is `None` for a market order (fills at the next
70    /// open) or a resting limit/stop level (fills when the bar reaches it).
71    Enter {
72        side: Side,
73        trigger: Option<(f64, LevelKind)>,
74    },
75    /// A market exit, fills at the next open.
76    Exit(&'static str),
77}
78
79/// A working order, decided on a bar's close and filled on a later bar. `delay`
80/// counts down the simulated execution latency before the order is eligible.
81#[derive(Debug)]
82struct Pending {
83    action: Action,
84    delay: u32,
85}
86
87/// Fill price for a resting level order against a bar, or `None` if not reached.
88/// A buy fills at the open when it gaps through the level (open below a limit,
89/// above a stop), otherwise at the level; a sell mirrors this.
90fn level_fill(side: Side, trigger: f64, kind: LevelKind, c: &Candle) -> Option<f64> {
91    let is_buy = matches!(side, Side::Long);
92    match (is_buy, kind) {
93        (true, LevelKind::Limit) => (c.low <= trigger).then(|| c.open.min(trigger)),
94        (true, LevelKind::Stop) => (c.high >= trigger).then(|| c.open.max(trigger)),
95        (false, LevelKind::Limit) => (c.high >= trigger).then(|| c.open.max(trigger)),
96        (false, LevelKind::Stop) => (c.low <= trigger).then(|| c.open.min(trigger)),
97        // A stop-limit needs both: the stop has to be touched, and the limit has
98        // to be reachable within the same bar. The second condition is the whole
99        // point of the order -- a stop that gaps far through its limit does not
100        // fill, where a plain stop would have filled at the open. `activation` is
101        // where the stop takes effect (the open when the bar gapped past it,
102        // otherwise the stop itself), and the limit caps how far the fill may
103        // travel from there.
104        (true, LevelKind::StopLimit { limit }) => {
105            (c.high >= trigger && c.low <= limit).then(|| limit.min(c.open.max(trigger)))
106        }
107        (false, LevelKind::StopLimit { limit }) => {
108            (c.low <= trigger && c.high >= limit).then(|| limit.max(c.open.min(trigger)))
109        }
110    }
111}
112
113/// The resting trigger level for an entry, or `None` for a market order. The
114/// level is the signal bar's close shifted by the configured limit/stop offset.
115fn entry_trigger(exec: &Execution, signal_close: f64) -> Option<(f64, LevelKind)> {
116    match exec.order_type {
117        OrderType::Limit => Some((
118            signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
119            LevelKind::Limit,
120        )),
121        OrderType::Stop => Some((
122            signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
123            LevelKind::Stop,
124        )),
125        OrderType::StopLimit => Some((
126            signal_close * (1.0 + exec.stop_offset_pct.unwrap_or(0.0) / 100.0),
127            LevelKind::StopLimit {
128                limit: signal_close * (1.0 + exec.limit_offset_pct.unwrap_or(0.0) / 100.0),
129            },
130        )),
131        OrderType::Market => None,
132    }
133}
134
135/// Realized per-bar return volatility (standard deviation of simple
136/// close-to-close returns) over the last `lookback` bars, or `None` if there is
137/// not enough history or the series is flat.
138fn realized_vol(history: &[BarRow], lookback: usize) -> Option<f64> {
139    if lookback < 2 || history.len() < lookback {
140        return None;
141    }
142    let closes: Vec<f64> = history[history.len() - lookback..]
143        .iter()
144        .map(|row| row.candle.close)
145        .collect();
146    let rets: Vec<f64> = closes
147        .windows(2)
148        .filter(|w| w[0].abs() > f64::EPSILON)
149        .map(|w| (w[1] - w[0]) / w[0])
150        .collect();
151    if rets.is_empty() {
152        return None;
153    }
154    let mean = rets.iter().sum::<f64>() / rets.len() as f64;
155    let var = rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / rets.len() as f64;
156    let sd = var.sqrt();
157    (sd > 0.0).then_some(sd)
158}
159
160/// Slippage rate (a fraction of price): fixed basis points, the order book's
161/// half-spread relative to the mid, or a linear function of the order's share of
162/// the bar volume. Missing inputs (no book / zero volume) yield zero.
163fn slippage_rate(
164    slippage: Slippage,
165    orderbook: Option<&CoreOrderBook>,
166    qty: f64,
167    volume: f64,
168) -> f64 {
169    match slippage {
170        Slippage::FixedBps { bps } => bps / 10_000.0,
171        Slippage::Spread => orderbook.map_or(0.0, |ob| match (ob.best_bid(), ob.best_ask()) {
172            (Some(bid), Some(ask)) => {
173                let mid = f64::midpoint(ask.price, bid.price);
174                if mid > 0.0 {
175                    (ask.price - bid.price) / 2.0 / mid
176                } else {
177                    0.0
178                }
179            }
180            _ => 0.0,
181        }),
182        Slippage::VolumeImpact { coef } => {
183            if volume > 0.0 {
184                coef * qty.abs() / volume
185            } else {
186                0.0
187            }
188        }
189    }
190}
191
192/// Context an entry/exit fill needs from the run loop.
193struct FillCtx<'a> {
194    spec: &'a StrategySpec,
195    candle: &'a Candle,
196    history: &'a [BarRow],
197    orderbook: Option<&'a CoreOrderBook>,
198    maker: f64,
199    taker: f64,
200    bar: usize,
201}
202
203/// Open a position at `raw_price` (before slippage), honouring the sizing model,
204/// leverage caps and volume-participation partial fills. `maker_fill` charges
205/// the maker fee (resting limit fills) instead of the taker fee.
206fn execute_entry(
207    side: Side,
208    raw_price: f64,
209    maker_fill: bool,
210    ctx: &FillCtx,
211    pf: &mut Portfolio,
212    entry_bar: &mut Option<usize>,
213    extreme: &mut f64,
214) -> Result<()> {
215    let dir = match side {
216        Side::Long => 1.0,
217        Side::Short => -1.0,
218    };
219    let rv = match ctx.spec.sizing {
220        Sizing::VolTarget { lookback, .. } => realized_vol(ctx.history, lookback as usize),
221        _ => None,
222    };
223    // Volume-impact slippage needs the order size; probe it at the raw price.
224    let probe_qty = match ctx.spec.costs.slippage {
225        Slippage::VolumeImpact { .. } => {
226            size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, raw_price, rv)?.unwrap_or(0.0)
227        }
228        _ => 0.0,
229    };
230    let slip = slippage_rate(
231        ctx.spec.costs.slippage,
232        ctx.orderbook,
233        probe_qty,
234        ctx.candle.volume,
235    );
236    let fill = raw_price * (1.0 + dir * slip);
237    if let Some(base) = size(ctx.spec.sizing, &ctx.spec.risk, pf.cash, fill, rv)? {
238        // Immediate-or-cancel partial fills: take at most a participation cap of
239        // the bar's volume.
240        let base = if ctx.spec.execution.partial_fills {
241            let cap = ctx.spec.execution.max_participation.unwrap_or(0.0) * ctx.candle.volume;
242            base.min(cap)
243        } else {
244            base
245        };
246        if base > 0.0 {
247            let rate = if maker_fill { ctx.maker } else { ctx.taker };
248            let fee = base * fill * rate;
249            pf.enter(dir * base, fill, ctx.candle.time, fee);
250            *entry_bar = Some(ctx.bar);
251            *extreme = fill;
252        }
253    }
254    Ok(())
255}
256
257/// Close the open position at `raw_price` (before slippage).
258fn execute_exit(
259    reason: &'static str,
260    raw_price: f64,
261    ctx: &FillCtx,
262    pf: &mut Portfolio,
263    entry_bar: &mut Option<usize>,
264) {
265    if !pf.in_position() {
266        return;
267    }
268    // Long exit sells (fills lower), short exit buys (fills higher).
269    let dir = if pf.is_long() { -1.0 } else { 1.0 };
270    let slip = slippage_rate(
271        ctx.spec.costs.slippage,
272        ctx.orderbook,
273        pf.qty,
274        ctx.candle.volume,
275    );
276    let fill = raw_price * (1.0 + dir * slip);
277    let fee = pf.qty.abs() * fill * ctx.taker;
278    pf.exit(fill, ctx.candle.time, fee, reason);
279    *entry_bar = None;
280}
281
282/// The optional non-OHLCV feeds for one bar: a reference-series close (pairwise),
283/// a derivatives tick (derivatives) and an order-book snapshot (order-book).
284/// Absent feeds are `None`; indicators that need a missing feed yield nothing.
285#[derive(Debug, Default)]
286pub struct Feeds<'a> {
287    /// Reference-series close for pairwise indicators.
288    pub reference: Option<f64>,
289    /// Derivatives tick for derivatives indicators.
290    pub deriv: Option<&'a DerivativesTick>,
291    /// Order-book snapshot for order-book indicators.
292    pub orderbook: Option<&'a OrderBook>,
293    /// Trades that printed within this bar, for trade-flow indicators.
294    pub trades: Option<&'a [TradePrint]>,
295    /// Market cross-section for this bar, for breadth indicators.
296    pub cross_section: Option<&'a CrossSection>,
297}
298
299/// One bar's inputs, converted from the wire types once and handed to each phase.
300///
301/// The conversions are not free and more than one phase needs them, so they
302/// happen here rather than per phase. `index` is the bar's absolute position in
303/// the run, which is not the same as its position in the retained window.
304#[derive(Debug)]
305struct Bar<'a> {
306    candle: &'a Candle,
307    reference: Option<f64>,
308    deriv: Option<CoreDerivativesTick>,
309    orderbook: Option<CoreOrderBook>,
310    cross_section: Option<CoreCrossSection>,
311    trades: Vec<CoreTrade>,
312    index: usize,
313}
314
315/// One declared indicator, with the keys its values are recorded under.
316///
317/// The keys are built once and shared into every `BarRow`. Recording a value used
318/// to clone the indicator's name into a fresh `String`, and a multi-output field
319/// used to `format!` one per bar -- allocations proportional to the length of the
320/// run, which for a live loop has no length.
321struct Indicator {
322    name: Arc<str>,
323    /// `name.field` keys, filled the first time a field is reported: the field
324    /// names come from the indicator, so they are not known before it runs.
325    field_keys: Vec<(&'static str, Arc<str>)>,
326    eval: Box<dyn EvalIndicator>,
327}
328
329/// How many bars the evaluator must be able to reach, including the current one.
330///
331/// Every backward-looking form declares its own depth in `rules`, so this is the
332/// maximum over the spec's rules plus whatever the sizing model reads. Sized this
333/// way the window answers exactly the questions an unbounded history would, and
334/// no more.
335fn history_depth(spec: &StrategySpec) -> usize {
336    let mut back = condition_lookback(&spec.entry).max(condition_lookback(&spec.exit));
337    if let Some(cond) = &spec.short_entry {
338        back = back.max(condition_lookback(cond));
339    }
340    if let Some(cond) = &spec.short_exit {
341        back = back.max(condition_lookback(cond));
342    }
343    // Vol targeting reads the last `lookback` closes off the tail.
344    if let Sizing::VolTarget { lookback, .. } = spec.sizing {
345        back = back.max(lookback as usize);
346    }
347    back + 1
348}
349
350/// Reject a spec that prices a run against a feed the run does not carry.
351///
352/// Both cases below produce a number rather than a failure when the feed is
353/// missing: spread slippage costs zero, and funding is never charged. The report
354/// then looks like a successful backtest of a cheaper strategy than the one that
355/// was described, which is the expensive kind of wrong -- nothing about it says
356/// the model was silently reduced.
357///
358/// The batch entry points know their feeds up front, so they check here.
359/// [`StreamingBacktest`] cannot: its caller supplies feeds bar by bar, and
360/// whether a book arrives is not knowable when the handle is built.
361pub(crate) fn require_feeds(
362    spec: &StrategySpec,
363    has_orderbook: bool,
364    has_deriv: bool,
365) -> Result<()> {
366    if matches!(spec.costs.slippage, Slippage::Spread) && !has_orderbook {
367        return Err(BacktestError::InvalidSpec(
368            "costs.slippage spread needs an order-book feed; without one every fill              would be priced at zero slippage"
369                .into(),
370        ));
371    }
372    if spec.costs.funding && !has_deriv {
373        return Err(BacktestError::InvalidSpec(
374            "costs.funding needs a derivatives feed; without one no funding would be              charged at all"
375                .into(),
376        ));
377    }
378    Ok(())
379}
380
381/// Run a backtest of `spec` over `candles` with the default capital.
382///
383/// ```
384/// use wickra_backtest_core::{run, Candle, StrategySpec};
385///
386/// let spec = StrategySpec::parse(
387///     r#"{"symbol":"x","timeframe":"1h","indicators":{},
388///         "entry":{"gt":[{"price":"close"},100]},
389///         "exit":{"lt":[{"price":"close"},100]},
390///         "sizing":{"type":"fixed_qty","qty":1}}"#,
391/// )?;
392/// let bar = |time, open: f64, close: f64| Candle {
393///     time,
394///     open,
395///     high: open.max(close),
396///     low: open.min(close),
397///     close,
398///     volume: 0.0,
399/// };
400/// let report = run(&spec, &[bar(0, 100.0, 101.0), bar(1, 102.0, 103.0), bar(2, 104.0, 97.0)])?;
401///
402/// // The entry signal fires on bar 0 and fills at bar 1's open, look-ahead-free.
403/// assert_eq!(report.trades.len(), report.metrics.num_trades as usize);
404/// assert_eq!(report.symbol, "x");
405/// # Ok::<(), wickra_backtest_core::BacktestError>(())
406/// ```
407///
408/// # Errors
409///
410/// Returns an error if the spec is invalid, the candle series is empty, or the
411/// spec prices against a feed this entry point cannot supply.
412pub fn run(spec: &StrategySpec, candles: &[Candle]) -> Result<BacktestReport> {
413    run_with_capital(spec, candles, DEFAULT_CAPITAL)
414}
415
416/// Run a backtest with explicit starting `capital`.
417pub fn run_with_capital(
418    spec: &StrategySpec,
419    candles: &[Candle],
420    capital: f64,
421) -> Result<BacktestReport> {
422    spec.validate()?;
423    require_feeds(spec, false, false)?;
424    if candles.is_empty() {
425        return Err(BacktestError::InvalidData("no candles".into()));
426    }
427    let mut bt = StreamingBacktest::new(spec, capital)?;
428    for candle in candles {
429        bt.step(candle)?;
430    }
431    Ok(bt.finish())
432}
433
434/// Run a backtest over a candle stream, invoking `on_bar` with the streaming
435/// state after each bar — the streaming entry point for a live tail or for
436/// emitting the equity curve incrementally.
437///
438/// This is exactly the same step loop as [`run_with_capital`], so the returned
439/// report is byte-identical; `on_bar` simply observes the state after each
440/// [`StreamingBacktest::step`] (e.g. to read [`StreamingBacktest::latest_equity`]).
441/// Pointing the same loop at a live feed turns the engine into the live bot.
442pub fn run_stream<F>(
443    spec: &StrategySpec,
444    candles: &[Candle],
445    capital: f64,
446    mut on_bar: F,
447) -> Result<BacktestReport>
448where
449    F: FnMut(usize, &StreamingBacktest),
450{
451    spec.validate()?;
452    require_feeds(spec, false, false)?;
453    if candles.is_empty() {
454        return Err(BacktestError::InvalidData("no candles".into()));
455    }
456    let mut bt = StreamingBacktest::new(spec, capital)?;
457    for (i, candle) in candles.iter().enumerate() {
458        bt.step(candle)?;
459        on_bar(i, &bt);
460    }
461    Ok(bt.finish())
462}
463
464/// Run a backtest with a reference price series for pairwise indicators. The
465/// reference candle at each index supplies the second input (its close) to
466/// pairwise indicators such as correlation, beta or spread. `reference` must be
467/// the same length as `candles`.
468pub fn run_with_ref(
469    spec: &StrategySpec,
470    candles: &[Candle],
471    reference: &[Candle],
472    capital: f64,
473) -> Result<BacktestReport> {
474    spec.validate()?;
475    require_feeds(spec, false, false)?;
476    if candles.is_empty() {
477        return Err(BacktestError::InvalidData("no candles".into()));
478    }
479    if reference.len() != candles.len() {
480        return Err(BacktestError::InvalidData(
481            "reference series must have the same length as the candles".into(),
482        ));
483    }
484    let mut bt = StreamingBacktest::new(spec, capital)?;
485    for (candle, ref_candle) in candles.iter().zip(reference) {
486        bt.step_with_ref(candle, Some(ref_candle.close))?;
487    }
488    Ok(bt.finish())
489}
490
491/// Run a backtest with a per-bar derivatives feed for derivatives indicators
492/// (funding, open interest, long/short ratio, …). `derivs` must be the same
493/// length as `candles`.
494pub fn run_with_deriv(
495    spec: &StrategySpec,
496    candles: &[Candle],
497    derivs: &[DerivativesTick],
498    capital: f64,
499) -> Result<BacktestReport> {
500    spec.validate()?;
501    require_feeds(spec, false, true)?;
502    if candles.is_empty() {
503        return Err(BacktestError::InvalidData("no candles".into()));
504    }
505    if derivs.len() != candles.len() {
506        return Err(BacktestError::InvalidData(
507            "derivatives feed must have the same length as the candles".into(),
508        ));
509    }
510    let mut bt = StreamingBacktest::new(spec, capital)?;
511    for (candle, d) in candles.iter().zip(derivs) {
512        bt.step_with_feeds(
513            candle,
514            &Feeds {
515                deriv: Some(d),
516                ..Default::default()
517            },
518        )?;
519    }
520    Ok(bt.finish())
521}
522
523/// Run a backtest with a per-bar order-book feed for order-book indicators
524/// (imbalance, microprice, quoted spread, …). `books` must be the same length
525/// as `candles`.
526pub fn run_with_orderbook(
527    spec: &StrategySpec,
528    candles: &[Candle],
529    books: &[OrderBook],
530    capital: f64,
531) -> Result<BacktestReport> {
532    spec.validate()?;
533    require_feeds(spec, true, false)?;
534    if candles.is_empty() {
535        return Err(BacktestError::InvalidData("no candles".into()));
536    }
537    if books.len() != candles.len() {
538        return Err(BacktestError::InvalidData(
539            "order-book feed must have the same length as the candles".into(),
540        ));
541    }
542    let mut bt = StreamingBacktest::new(spec, capital)?;
543    for (candle, ob) in candles.iter().zip(books) {
544        bt.step_with_feeds(
545            candle,
546            &Feeds {
547                orderbook: Some(ob),
548                ..Default::default()
549            },
550        )?;
551    }
552    Ok(bt.finish())
553}
554
555/// Run a backtest with a per-bar trade feed for trade-flow indicators (CVD,
556/// trade imbalance, VPIN, signed volume, …). `trades[i]` is the list of trades
557/// that printed within bar `i`; the outer length must match `candles`.
558pub fn run_with_trades(
559    spec: &StrategySpec,
560    candles: &[Candle],
561    trades: &[Vec<TradePrint>],
562    capital: f64,
563) -> Result<BacktestReport> {
564    spec.validate()?;
565    require_feeds(spec, false, false)?;
566    if candles.is_empty() {
567        return Err(BacktestError::InvalidData("no candles".into()));
568    }
569    if trades.len() != candles.len() {
570        return Err(BacktestError::InvalidData(
571            "trade feed must have one trade list per candle".into(),
572        ));
573    }
574    let mut bt = StreamingBacktest::new(spec, capital)?;
575    for (candle, bar_trades) in candles.iter().zip(trades) {
576        bt.step_with_feeds(
577            candle,
578            &Feeds {
579                trades: Some(bar_trades.as_slice()),
580                ..Default::default()
581            },
582        )?;
583    }
584    Ok(bt.finish())
585}
586
587/// Run a backtest with a per-bar market cross-section for breadth indicators
588/// (advance/decline, `McClellan`, TRIN, …). `sections` must be the same length as
589/// `candles`.
590pub fn run_with_cross_section(
591    spec: &StrategySpec,
592    candles: &[Candle],
593    sections: &[CrossSection],
594    capital: f64,
595) -> Result<BacktestReport> {
596    spec.validate()?;
597    require_feeds(spec, false, false)?;
598    if candles.is_empty() {
599        return Err(BacktestError::InvalidData("no candles".into()));
600    }
601    if sections.len() != candles.len() {
602        return Err(BacktestError::InvalidData(
603            "cross-section feed must have one panel per candle".into(),
604        ));
605    }
606    let mut bt = StreamingBacktest::new(spec, capital)?;
607    for (candle, cs) in candles.iter().zip(sections) {
608        bt.step_with_feeds(
609            candle,
610            &Feeds {
611                cross_section: Some(cs),
612                ..Default::default()
613            },
614        )?;
615    }
616    Ok(bt.finish())
617}
618
619/// A streaming backtest: feed bars one at a time with [`StreamingBacktest::step`],
620/// then [`StreamingBacktest::finish`]. The historical runner is exactly this fed
621/// from a slice, so **backtest and live share one code path** — point `step` at
622/// a live feed and the same engine becomes the live bot.
623///
624/// # Memory over a long run
625///
626/// Bar history is bounded: only as many rows are retained as the spec's rules can
627/// reach back, so feeding it forever does not grow it.
628///
629/// The equity curve and closed trades are not bounded, and deliberately so —
630/// [`StreamingBacktest::finish`] computes every metric over the whole series, so
631/// discarding points would quietly narrow the report rather than shrink it. An
632/// equity point is 16 bytes, so a year of 1-minute bars costs roughly 8 MB. A live
633/// consumer that reads [`StreamingBacktest::latest_equity`] each bar and persists
634/// it elsewhere never needs the accumulated copy; `finish` is what releases it,
635/// and starting a fresh run costs the indicators their warmup again.
636pub struct StreamingBacktest<'a> {
637    spec: Cow<'a, StrategySpec>,
638    capital: f64,
639    maker: f64,
640    taker: f64,
641    warmup: usize,
642    indicators: Vec<Indicator>,
643    pf: Portfolio,
644    // The most recent `history_depth` bars, not the whole run: the evaluator only
645    // ever indexes backwards by a bounded amount, and retaining everything made
646    // memory grow with the length of the feed. A live loop is a run that never
647    // ends, so "the whole history" is not a size at all.
648    history: Vec<BarRow>,
649    history_depth: usize,
650    // Bars fed so far. `history.len()` used to serve as this; once the window is
651    // bounded the two part company, and entry bookkeeping needs the absolute one.
652    bars_seen: usize,
653    equity: Vec<EquityPoint>,
654    pending: Option<Pending>,
655    entry_bar: Option<usize>,
656    // Most favourable price reached since entry (peak for a long, trough for a
657    // short) — the reference for the trailing stop.
658    extreme: f64,
659    // (time, close) of the most recent bar, for the final mark-out.
660    last: Option<(i64, f64)>,
661}
662
663/// Hand-written because the indicator map holds `Box<dyn EvalIndicator>`, which no
664/// derive can reach. The evaluators are shown by name: their internal state is the
665/// indicator's business, and printing it would make this unreadable at any real
666/// bar count.
667impl fmt::Debug for StreamingBacktest<'_> {
668    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
669        f.debug_struct("StreamingBacktest")
670            .field("capital", &self.capital)
671            .field("warmup", &self.warmup)
672            .field(
673                "indicators",
674                &self.indicators.iter().map(|i| &*i.name).collect::<Vec<_>>(),
675            )
676            .field("bars", &self.bars_seen)
677            .field("equity_points", &self.equity.len())
678            .field("trades", &self.pf.trades.len())
679            .field("pending", &self.pending)
680            .field("entry_bar", &self.entry_bar)
681            .finish_non_exhaustive()
682    }
683}
684
685impl<'a> StreamingBacktest<'a> {
686    /// Build a streaming backtest from a validated spec and starting capital.
687    ///
688    /// ```
689    /// use wickra_backtest_core::{run_with_capital, Candle, StreamingBacktest, StrategySpec};
690    ///
691    /// let spec = StrategySpec::parse(
692    ///     r#"{"symbol":"x","timeframe":"1h","indicators":{},
693    ///         "entry":{"gt":[{"price":"close"},100]},
694    ///         "exit":{"lt":[{"price":"close"},100]},
695    ///         "sizing":{"type":"fixed_qty","qty":1}}"#,
696    /// )?;
697    /// let bar = |time, open: f64, close: f64| Candle {
698    ///     time,
699    ///     open,
700    ///     high: open.max(close),
701    ///     low: open.min(close),
702    ///     close,
703    ///     volume: 0.0,
704    /// };
705    /// let candles = [bar(0, 100.0, 101.0), bar(1, 102.0, 103.0), bar(2, 104.0, 97.0)];
706    ///
707    /// let mut live = StreamingBacktest::new(&spec, 1_000.0)?;
708    /// for candle in &candles {
709    ///     live.step(candle)?;
710    ///     // Everything a live loop wants is readable between bars.
711    ///     let _ = (live.num_trades(), live.latest_equity());
712    /// }
713    /// let streamed = live.finish();
714    ///
715    /// // Feeding the same bars from a slice is the historical runner, and with
716    /// // the same capital it produces the same report -- the whole claim of this
717    /// // crate. (`run` would use the default capital and disagree, which is a
718    /// // difference in inputs, not in engines.)
719    /// let batch = run_with_capital(&spec, &candles, 1_000.0)?;
720    /// assert_eq!(streamed.equity, batch.equity);
721    /// assert_eq!(streamed.metrics.pnl, batch.metrics.pnl);
722    /// # Ok::<(), wickra_backtest_core::BacktestError>(())
723    /// ```
724    pub fn new(spec: &'a StrategySpec, capital: f64) -> Result<Self> {
725        Self::from_spec(Cow::Borrowed(spec), capital)
726    }
727
728    /// Build from an owned-or-borrowed spec — the shared constructor behind
729    /// [`StreamingBacktest::new`] and [`StreamingBacktest::new_owned`].
730    fn from_spec(spec: Cow<'a, StrategySpec>, capital: f64) -> Result<Self> {
731        spec.validate()?;
732        // `spec.indicators` is a sorted map, so the order here is deterministic.
733        let mut indicators: Vec<Indicator> = Vec::with_capacity(spec.indicators.len());
734        let mut max_warmup = 0usize;
735        for (name, ind) in &spec.indicators {
736            let built = registry::build(&ind.kind, &ind.params)?;
737            max_warmup = max_warmup.max(built.warmup());
738            indicators.push(Indicator {
739                name: Arc::from(name.as_str()),
740                field_keys: Vec::new(),
741                eval: built,
742            });
743        }
744        let warmup = spec.warmup.map_or(max_warmup, |w| w as usize);
745        let history_depth = history_depth(&spec);
746        let maker = spec.costs.maker_bps / 10_000.0;
747        let taker = spec.costs.taker_bps / 10_000.0;
748        Ok(Self {
749            spec,
750            capital,
751            maker,
752            taker,
753            warmup,
754            indicators,
755            pf: Portfolio::new(capital),
756            history: Vec::with_capacity(history_depth),
757            history_depth,
758            bars_seen: 0,
759            equity: Vec::new(),
760            pending: None,
761            entry_bar: None,
762            extreme: 0.0,
763            last: None,
764        })
765    }
766
767    /// Process one bar: fill the working order, update indicators, check intrabar
768    /// stops, mark equity and decide the next action. Look-ahead-free.
769    pub fn step(&mut self, candle: &Candle) -> Result<()> {
770        self.step_with_feeds(candle, &Feeds::default())
771    }
772
773    /// The equity points produced so far, oldest first. Readable after each
774    /// `step` for a live tail of the equity curve.
775    pub fn equity(&self) -> &[EquityPoint] {
776        &self.equity
777    }
778
779    /// The most recent equity point, or `None` before the first bar is marked.
780    /// This is the value to emit per bar in a streaming / live run.
781    pub fn latest_equity(&self) -> Option<EquityPoint> {
782        self.equity.last().copied()
783    }
784
785    /// The number of completed trades so far.
786    pub fn num_trades(&self) -> usize {
787        self.pf.trades.len()
788    }
789
790    /// Like [`StreamingBacktest::step`], but also supplies the reference series'
791    /// close for this bar, which pairwise indicators consume as their second
792    /// input. Single-instrument indicators ignore it.
793    pub fn step_with_ref(&mut self, candle: &Candle, reference: Option<f64>) -> Result<()> {
794        self.step_with_feeds(
795            candle,
796            &Feeds {
797                reference,
798                ..Default::default()
799            },
800        )
801    }
802
803    /// Process one bar with its optional non-OHLCV [`Feeds`]. Pairwise indicators
804    /// consume the reference; derivatives / order-book indicators consume the
805    /// tick / snapshot; other indicators ignore them.
806    /// Advance the simulation by one bar.
807    ///
808    /// # Errors
809    ///
810    /// Returns an error if the bar cannot be priced the way the spec asks.
811    pub fn step_with_feeds(&mut self, candle: &Candle, feeds: &Feeds) -> Result<()> {
812        // Checked per bar, because that is the only place a streaming caller can
813        // be checked: the batch entry points know the whole run's feeds up front
814        // and reject a mismatched spec once, but here they arrive one bar at a
815        // time. The standard is the same either way -- the batch path requires a
816        // book for every candle, not merely for some -- so a bar that cannot be
817        // priced the way the spec asks is rejected rather than priced as if it
818        // could be.
819        require_feeds(&self.spec, feeds.orderbook.is_some(), feeds.deriv.is_some())?;
820        let bar = Bar {
821            candle,
822            reference: feeds.reference,
823            deriv: feeds.deriv.and_then(|d| d.to_core().ok()),
824            orderbook: feeds.orderbook.and_then(|ob| ob.to_core().ok()),
825            cross_section: feeds.cross_section.and_then(|cs| cs.to_core().ok()),
826            trades: feeds
827                .trades
828                .unwrap_or(&[])
829                .iter()
830                .filter_map(|tp| tp.to_core().ok())
831                .collect(),
832            index: self.bars_seen,
833        };
834        self.last = Some((candle.time, candle.close));
835
836        // The order below is the correctness argument, not a matter of taste. A
837        // fill is priced against this bar before the bar is recorded, so a rule
838        // cannot see the close it is about to be filled at; indicators update
839        // before intrabar stops read them; equity is marked after every cost has
840        // been charged; and only then does the next signal get to look at the
841        // completed bar. Reordering any two of these is a change in what the
842        // engine means, which is why they are named here rather than left as
843        // comment headings inside one long body.
844        self.fill_working_order(&bar)?;
845        let idx = self.record_bar(&bar);
846        self.apply_intrabar_exits(&bar);
847        self.charge_funding(&bar);
848        self.mark_equity(&bar);
849        self.decide_next_action(&bar, idx)
850    }
851
852    /// 1. Fill the working order against this bar, look-ahead-free.
853    fn fill_working_order(&mut self, bar: &Bar) -> Result<()> {
854        let candle = bar.candle;
855        let orderbook = &bar.orderbook;
856        let t = bar.index;
857        // 1. Fill the working order against this bar (look-ahead-free). Execution
858        //    latency counts down first; then a market order fills at the open and
859        //    a resting limit/stop fills only when the bar reaches its level —
860        //    otherwise the order keeps working into the next bar.
861        if let Some(mut order) = self.pending.take() {
862            if order.delay > 0 {
863                order.delay -= 1;
864                self.pending = Some(order); // still waiting on latency
865            } else {
866                let ctx = FillCtx {
867                    spec: &self.spec,
868                    candle,
869                    history: &self.history,
870                    maker: self.maker,
871                    taker: self.taker,
872                    orderbook: orderbook.as_ref(),
873                    bar: t,
874                };
875                let keep_working = match &order.action {
876                    Action::Enter { side, trigger } => {
877                        let side = *side;
878                        // A resting limit fill provides liquidity → maker fee.
879                        let maker_fill = matches!(trigger, Some((_, LevelKind::Limit)));
880                        let level = match trigger {
881                            None => Some(candle.open),
882                            Some((trig, kind)) => level_fill(side, *trig, *kind, candle),
883                        };
884                        match level {
885                            Some(px) => {
886                                execute_entry(
887                                    side,
888                                    px,
889                                    maker_fill,
890                                    &ctx,
891                                    &mut self.pf,
892                                    &mut self.entry_bar,
893                                    &mut self.extreme,
894                                )?;
895                                false
896                            }
897                            None => true, // level not reached; the order keeps working
898                        }
899                    }
900                    Action::Exit(reason) => {
901                        execute_exit(reason, candle.open, &ctx, &mut self.pf, &mut self.entry_bar);
902                        false
903                    }
904                };
905                if keep_working {
906                    self.pending = Some(order);
907                }
908            }
909        }
910
911        Ok(())
912    }
913
914    /// 2. Update every indicator and record the bar.
915    ///
916    /// Returns the bar's index in the retained window, which is not its index in
917    /// the run: the window is bounded and the run is not.
918    fn record_bar(&mut self, bar: &Bar) -> usize {
919        let candle = bar.candle;
920        let reference = bar.reference;
921        let deriv = bar.deriv;
922        let orderbook = &bar.orderbook;
923        let cross_section = &bar.cross_section;
924        let trades: &[CoreTrade] = &bar.trades;
925        // 2. Update indicators and record the bar.
926        let mut values = BTreeMap::new();
927        for ind in &mut self.indicators {
928            let input = BarInput {
929                candle,
930                reference,
931                deriv,
932                orderbook: orderbook.as_ref(),
933                trades,
934                cross_section: cross_section.as_ref(),
935            };
936            if let Some(v) = ind.eval.update(&input) {
937                values.insert(Arc::clone(&ind.name), v);
938                let fields = ind.eval.fields();
939                for (field, fv) in fields {
940                    // Built once per field, then shared: a linear scan over a
941                    // handful of names costs less than formatting one per bar.
942                    let key =
943                        if let Some((_, key)) = ind.field_keys.iter().find(|(f, _)| *f == field) {
944                            Arc::clone(key)
945                        } else {
946                            let key: Arc<str> = Arc::from(format!("{}.{field}", ind.name).as_str());
947                            ind.field_keys.push((field, Arc::clone(&key)));
948                            key
949                        };
950                    values.insert(key, fv);
951                }
952            }
953        }
954        let row = BarRow {
955            candle: *candle,
956            values,
957        };
958        if self.history.len() == self.history_depth {
959            // Full: drop the oldest and keep the slice contiguous, since the
960            // evaluator indexes into it directly.
961            self.history.rotate_left(1);
962            self.history[self.history_depth - 1] = row;
963        } else {
964            self.history.push(row);
965        }
966        self.bars_seen += 1;
967        // Window-relative: the current bar is always the last retained one.
968        self.history.len() - 1
969    }
970
971    /// 3. Intrabar stop-loss / take-profit / trailing-stop against this bar.
972    fn apply_intrabar_exits(&mut self, bar: &Bar) {
973        let candle = bar.candle;
974        // 3. Intrabar stop-loss / take-profit / trailing-stop against this bar's OHLC.
975        if self.pf.in_position() {
976            // Extend the favourable extreme with this bar before checking the trail.
977            self.extreme = if self.pf.is_long() {
978                self.extreme.max(candle.high)
979            } else {
980                self.extreme.min(candle.low)
981            };
982            if let Some((price, reason)) = intrabar_exit(
983                candle,
984                &self.spec.risk,
985                self.pf.entry_price,
986                self.extreme,
987                self.pf.is_long(),
988            ) {
989                let fee = self.pf.qty.abs() * price * self.taker;
990                self.pf.exit(price, candle.time, fee, reason);
991                self.entry_bar = None;
992            } else if self.spec.risk.liquidation {
993                // Bankruptcy price: account equity (cash + qty * price) reaches 0.
994                let p_liq = -self.pf.cash / self.pf.qty;
995                let breached = if self.pf.is_long() {
996                    candle.low <= p_liq
997                } else {
998                    candle.high >= p_liq
999                };
1000                if p_liq > 0.0 && breached {
1001                    let fee = self.pf.qty.abs() * p_liq * self.taker;
1002                    self.pf.exit(p_liq, candle.time, fee, "liquidation");
1003                    self.entry_bar = None;
1004                }
1005            }
1006        }
1007    }
1008
1009    /// 3b. Charge perpetual funding to an open position from the feed.
1010    fn charge_funding(&mut self, bar: &Bar) {
1011        let deriv = bar.deriv;
1012        // 3b. Charge perpetual funding to the open position from the feed.
1013        if self.spec.costs.funding && self.pf.in_position() {
1014            if let Some(d) = deriv {
1015                // Longs (qty > 0) pay when the rate is positive; shorts receive.
1016                let payment = self.pf.qty * d.mark_price * d.funding_rate;
1017                self.pf.apply_funding(payment);
1018            }
1019        }
1020    }
1021
1022    /// 4. Mark equity at the close.
1023    fn mark_equity(&mut self, bar: &Bar) {
1024        let candle = bar.candle;
1025        // 4. Mark equity at the close.
1026        self.equity.push(EquityPoint {
1027            time: candle.time,
1028            equity: self.pf.equity(candle.close),
1029        });
1030    }
1031
1032    /// 5. Decide the next signal action.
1033    fn decide_next_action(&mut self, bar: &Bar, idx: usize) -> Result<()> {
1034        let candle = bar.candle;
1035        let orderbook = &bar.orderbook;
1036        let t = bar.index;
1037        // 5. Decide the next signal action. Skip warmup.
1038        //
1039        // Counted in bars fed, not in retained rows: warmup is about indicators
1040        // having seen enough input, which is their own state, not this window's
1041        // depth. Reading it off the window would stall the gate forever once the
1042        // window filled.
1043        if t < self.warmup {
1044            return Ok(());
1045        }
1046        let bars_since_entry = self.entry_bar.map(|e| (t - e) as u32);
1047        let state = RuleState {
1048            in_position: self.pf.in_position(),
1049            bars_since_entry,
1050        };
1051        // Close-to-close mode fills on this very bar's close; otherwise the order
1052        // rests and fills on a later bar (the look-ahead-free default).
1053        let close_fill = matches!(self.spec.execution.fill_timing, FillTiming::Close);
1054
1055        if self.pf.in_position() {
1056            let cond = if self.pf.is_long() {
1057                &self.spec.exit
1058            } else {
1059                self.spec.short_exit.as_ref().unwrap_or(&self.spec.exit)
1060            };
1061            if eval_condition(cond, &self.history, idx, state) {
1062                if close_fill {
1063                    let ctx = FillCtx {
1064                        spec: &self.spec,
1065                        candle,
1066                        history: &self.history,
1067                        maker: self.maker,
1068                        taker: self.taker,
1069                        orderbook: orderbook.as_ref(),
1070                        bar: t,
1071                    };
1072                    execute_exit(
1073                        "signal",
1074                        candle.close,
1075                        &ctx,
1076                        &mut self.pf,
1077                        &mut self.entry_bar,
1078                    );
1079                } else {
1080                    self.pending = Some(Pending {
1081                        action: Action::Exit("signal"),
1082                        delay: self.spec.execution.latency_bars,
1083                    });
1084                }
1085            }
1086        } else if self.pending.is_none() {
1087            // No order working: a new entry signal places one. Its trigger is the
1088            // signal bar's close shifted by the configured limit/stop offset.
1089            let entry_fires = eval_condition(&self.spec.entry, &self.history, idx, state);
1090            let short_fires = !entry_fires
1091                && self
1092                    .spec
1093                    .short_entry
1094                    .as_ref()
1095                    .is_some_and(|c| eval_condition(c, &self.history, idx, state));
1096            let side = if entry_fires {
1097                Some(Side::Long)
1098            } else if short_fires {
1099                Some(Side::Short)
1100            } else {
1101                None
1102            };
1103            if let Some(side) = side {
1104                if close_fill {
1105                    let ctx = FillCtx {
1106                        spec: &self.spec,
1107                        candle,
1108                        history: &self.history,
1109                        maker: self.maker,
1110                        taker: self.taker,
1111                        orderbook: orderbook.as_ref(),
1112                        bar: t,
1113                    };
1114                    execute_entry(
1115                        side,
1116                        candle.close,
1117                        false, // close-to-close fills are market (taker)
1118                        &ctx,
1119                        &mut self.pf,
1120                        &mut self.entry_bar,
1121                        &mut self.extreme,
1122                    )?;
1123                } else {
1124                    let trigger = entry_trigger(&self.spec.execution, candle.close);
1125                    self.pending = Some(Pending {
1126                        action: Action::Enter { side, trigger },
1127                        delay: self.spec.execution.latency_bars,
1128                    });
1129                }
1130            }
1131        }
1132        Ok(())
1133    }
1134
1135    /// Close any open position at the last bar's close and produce the report.
1136    pub fn finish(mut self) -> BacktestReport {
1137        if self.pf.in_position() {
1138            if let Some((time, close)) = self.last {
1139                let fee = self.pf.qty.abs() * close * self.taker;
1140                self.pf.exit(close, time, fee, "end");
1141            }
1142        }
1143        let series: Vec<f64> = self.equity.iter().map(|e| e.equity).collect();
1144        let metrics = metrics::compute(self.capital, &series, &self.pf.trades);
1145        BacktestReport {
1146            schema_version: REPORT_SCHEMA_VERSION,
1147            symbol: self.spec.symbol.clone(),
1148            timeframe: self.spec.timeframe.clone(),
1149            metrics,
1150            trades: self.pf.trades,
1151            equity: self.equity,
1152            fees_paid: self.pf.fees_paid,
1153            initial_capital: self.capital,
1154        }
1155    }
1156}
1157
1158impl StreamingBacktest<'static> {
1159    /// Build a streaming backtest that **owns** its spec, so the handle carries
1160    /// no borrow and can be held across `step`s indefinitely — for embedders
1161    /// that cannot thread a borrow through their own lifetime, such as a
1162    /// `#[wasm_bindgen]` handle driving the engine bar-by-bar in the browser.
1163    /// Otherwise identical to [`StreamingBacktest::new`].
1164    ///
1165    /// # Errors
1166    ///
1167    /// Returns an error if the spec fails validation.
1168    pub fn new_owned(spec: StrategySpec, capital: f64) -> Result<Self> {
1169        Self::from_spec(Cow::Owned(spec), capital)
1170    }
1171}
1172
1173/// Base (unsigned) quantity for the sizing model.
1174///
1175/// `equity` is the account equity at entry (the position is opened from flat, so
1176/// equity equals cash). The resulting notional is capped by the leverage and
1177/// position limits: without `risk.max_leverage` the cap is 1x equity — no
1178/// leverage by default — so an order can never exceed what the account can fund.
1179fn size(
1180    sizing: Sizing,
1181    risk: &Risk,
1182    equity: f64,
1183    price: f64,
1184    realized_vol: Option<f64>,
1185) -> Result<Option<f64>> {
1186    if price <= 0.0 || equity <= 0.0 {
1187        return Ok(None);
1188    }
1189    let qty = match sizing {
1190        Sizing::FixedFraction { fraction } => (equity * fraction) / price,
1191        Sizing::FixedCash { cash: notional } => notional / price,
1192        Sizing::FixedQty { qty } => qty,
1193        Sizing::RiskPerTrade { risk_pct } => {
1194            // Size so a stop-loss hit loses `risk_pct` of equity: the per-unit
1195            // loss is `price * stop_loss_pct`, so qty = risk_cash / per-unit loss.
1196            let stop = risk.stop_loss_pct.ok_or_else(|| {
1197                BacktestError::InvalidSpec(
1198                    "risk_per_trade sizing requires risk.stop_loss_pct".into(),
1199                )
1200            })?;
1201            if stop <= 0.0 {
1202                return Ok(None);
1203            }
1204            (equity * risk_pct / 100.0) / (price * stop / 100.0)
1205        }
1206        Sizing::VolTarget { target_vol, .. } => {
1207            // Scale notional so the position's per-bar return vol ~= target_vol.
1208            // No realized vol yet (warming up) => no position this bar.
1209            let Some(rv) = realized_vol else {
1210                return Ok(None);
1211            };
1212            (equity * target_vol / rv) / price
1213        }
1214    };
1215    if qty <= 0.0 {
1216        return Ok(None);
1217    }
1218    // Cap the notional by the leverage and position limits.
1219    let max_leverage = risk.max_leverage.unwrap_or(1.0);
1220    let mut max_notional = equity * max_leverage;
1221    if let Some(max_pct) = risk.max_position_pct {
1222        max_notional = max_notional.min(equity * max_pct / 100.0);
1223    }
1224    let capped = (qty * price).min(max_notional) / price;
1225    Ok(Some(capped))
1226}
1227
1228/// Intrabar stop-loss / trailing-stop / take-profit fill against the bar's OHLC.
1229///
1230/// `extreme` is the most favourable price reached since entry (peak for a long,
1231/// trough for a short), the trailing-stop reference. Conservative: when a bar's
1232/// range brackets several levels, the stop (then the trailing stop) is assumed
1233/// to fill before the target. Levels are side-aware (a short's stop is above
1234/// entry, its target below).
1235///
1236/// Fills are **gap-aware**: a stop fills at its level when price trades through
1237/// it intrabar, but if the bar *opens* beyond the level (a gap), the fill is the
1238/// open — the worse price for a stop, the better price for a take-profit — never
1239/// an unreachable level. A long stop fills at `min(level, open)`, a long target
1240/// at `max(level, open)`; a short is the mirror.
1241fn intrabar_exit(
1242    candle: &Candle,
1243    risk: &Risk,
1244    entry: f64,
1245    extreme: f64,
1246    is_long: bool,
1247) -> Option<(f64, &'static str)> {
1248    if entry <= 0.0 {
1249        return None;
1250    }
1251    if is_long {
1252        if let Some(p) = risk.stop_loss_pct {
1253            let level = entry * (1.0 - p / 100.0);
1254            if candle.low <= level {
1255                return Some((level.min(candle.open), "stop_loss"));
1256            }
1257        }
1258        if let Some(p) = risk.trailing_stop_pct {
1259            let level = extreme * (1.0 - p / 100.0);
1260            if candle.low <= level {
1261                return Some((level.min(candle.open), "trailing_stop"));
1262            }
1263        }
1264        if let Some(p) = risk.take_profit_pct {
1265            let level = entry * (1.0 + p / 100.0);
1266            if candle.high >= level {
1267                return Some((level.max(candle.open), "take_profit"));
1268            }
1269        }
1270    } else {
1271        if let Some(p) = risk.stop_loss_pct {
1272            let level = entry * (1.0 + p / 100.0);
1273            if candle.high >= level {
1274                return Some((level.max(candle.open), "stop_loss"));
1275            }
1276        }
1277        if let Some(p) = risk.trailing_stop_pct {
1278            let level = extreme * (1.0 + p / 100.0);
1279            if candle.high >= level {
1280                return Some((level.max(candle.open), "trailing_stop"));
1281            }
1282        }
1283        if let Some(p) = risk.take_profit_pct {
1284            let level = entry * (1.0 - p / 100.0);
1285            if candle.low <= level {
1286                return Some((level.min(candle.open), "take_profit"));
1287            }
1288        }
1289    }
1290    None
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use crate::data::Level;
1297    use crate::spec::StrategySpec;
1298
1299    fn bar(time: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
1300        Candle {
1301            time,
1302            open,
1303            high,
1304            low,
1305            close,
1306            volume: 0.0,
1307        }
1308    }
1309
1310    // --- stop-limit ---------------------------------------------------------
1311    //
1312    // A stop-limit is a stop that arms a limit. What distinguishes it from a
1313    // plain stop is the case where the market gaps past the limit: the stop
1314    // triggers, the limit is never reachable, and the order does not fill. A
1315    // plain stop would have filled at the open. These tests pin that difference,
1316    // because an implementation that ignored it would pass every other check.
1317
1318    #[test]
1319    fn buy_stop_limit_fills_at_the_stop_when_the_limit_is_above_it() {
1320        // Stop 100, limit 101. The bar trades up through 100, so the stop arms
1321        // and the limit buy at 101 is immediately marketable: it fills at 100,
1322        // better than the limit, never worse.
1323        let c = bar(0, 99.0, 100.5, 98.5, 100.2);
1324        let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1325        assert_eq!(fill, Some(100.0));
1326    }
1327
1328    #[test]
1329    fn buy_stop_limit_does_not_fill_when_the_bar_gaps_past_the_limit() {
1330        // Opens at 105, far above both stop and limit, and never trades back to
1331        // 101. The stop is touched; the limit is not. No fill.
1332        let c = bar(0, 105.0, 106.0, 102.0, 105.5);
1333        let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1334        assert_eq!(fill, None);
1335        // The same bar and the same stop, as a plain stop order, does fill --
1336        // at the open. That is exactly the protection a stop-limit buys.
1337        assert_eq!(
1338            level_fill(Side::Long, 100.0, LevelKind::Stop, &c),
1339            Some(105.0)
1340        );
1341    }
1342
1343    #[test]
1344    fn buy_stop_limit_fills_at_the_limit_when_price_comes_back() {
1345        // Gaps to 105, so the stop arms at the open, then trades back through
1346        // 101. It fills at the limit, not at the open.
1347        let c = bar(0, 105.0, 106.0, 100.5, 104.0);
1348        let fill = level_fill(Side::Long, 100.0, LevelKind::StopLimit { limit: 101.0 }, &c);
1349        assert_eq!(fill, Some(101.0));
1350    }
1351
1352    #[test]
1353    fn sell_stop_limit_mirrors_the_buy_side() {
1354        // Stop 100 below the market, limit 99. Trades down through 100 and
1355        // reaches 99: fills at 100, better than the limit.
1356        let touched = bar(0, 101.0, 101.5, 99.0, 99.5);
1357        assert_eq!(
1358            level_fill(
1359                Side::Short,
1360                100.0,
1361                LevelKind::StopLimit { limit: 99.0 },
1362                &touched
1363            ),
1364            Some(100.0)
1365        );
1366        // Gaps down to 95 and never trades back up to 99: no fill, where a plain
1367        // stop would have filled at the open.
1368        let gapped = bar(0, 95.0, 98.0, 94.0, 96.0);
1369        assert_eq!(
1370            level_fill(
1371                Side::Short,
1372                100.0,
1373                LevelKind::StopLimit { limit: 99.0 },
1374                &gapped
1375            ),
1376            None
1377        );
1378        assert_eq!(
1379            level_fill(Side::Short, 100.0, LevelKind::Stop, &gapped),
1380            Some(95.0)
1381        );
1382    }
1383
1384    #[test]
1385    fn stop_limit_never_fills_worse_than_its_limit() {
1386        // Whatever the bar does, a buy never pays more than the limit and a sell
1387        // never receives less.
1388        for (o, h, l, c) in [
1389            (99.0, 100.5, 98.5, 100.2),
1390            (105.0, 106.0, 100.5, 104.0),
1391            (100.2, 103.0, 100.1, 102.0),
1392        ] {
1393            let candle = bar(0, o, h, l, c);
1394            if let Some(px) = level_fill(
1395                Side::Long,
1396                100.0,
1397                LevelKind::StopLimit { limit: 101.0 },
1398                &candle,
1399            ) {
1400                assert!(px <= 101.0, "buy filled above its limit: {px}");
1401            }
1402        }
1403    }
1404
1405    // --- a run must carry the feeds its spec prices against ------------------
1406    //
1407    // Both of these used to produce a report. Spread slippage without a book cost
1408    // nothing, and funding without a derivatives feed was never charged, so the
1409    // run answered for a cheaper strategy than the one described and said nothing
1410    // about it.
1411
1412    fn oscillating(n: i64) -> Vec<Candle> {
1413        (0..n)
1414            .map(|i| {
1415                let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
1416                bar(i, px, px + 0.5, px - 0.5, px)
1417            })
1418            .collect()
1419    }
1420
1421    fn spec_with(costs: &str) -> StrategySpec {
1422        StrategySpec::parse(&format!(
1423            r#"{{"symbol":"x","timeframe":"1h",
1424                "indicators":{{"a":{{"type":"Sma","params":[5]}}}},
1425                "entry":{{"cross_above":[{{"price":"close"}},"a"]}},
1426                "exit":{{"cross_below":[{{"price":"close"}},"a"]}},
1427                "sizing":{{"type":"fixed_qty","qty":1}},
1428                "costs":{costs}}}"#
1429        ))
1430        .unwrap()
1431    }
1432
1433    #[test]
1434    fn the_report_says_what_it_is_a_report_of() {
1435        // Distinctive values on purpose: the golden corpus uses "x" and "1h"
1436        // throughout, so it would pass just as well against a hardcoded string.
1437        let spec = StrategySpec::parse(
1438            r#"{"symbol":"BTCUSDT","timeframe":"4h","indicators":{},
1439                "entry":{"gt":[{"price":"close"},100]},
1440                "exit":{"lt":[{"price":"close"},100]},
1441                "sizing":{"type":"fixed_qty","qty":1}}"#,
1442        )
1443        .unwrap();
1444        let candles = oscillating(20);
1445
1446        let batch = run(&spec, &candles).unwrap();
1447        assert_eq!(batch.symbol, "BTCUSDT");
1448        assert_eq!(batch.timeframe, "4h");
1449
1450        // The streaming path builds its report separately, so it is asserted
1451        // separately.
1452        let mut bt = StreamingBacktest::new(&spec, DEFAULT_CAPITAL).unwrap();
1453        for candle in &candles {
1454            bt.step(candle).unwrap();
1455        }
1456        let streamed = bt.finish();
1457        assert_eq!(streamed.symbol, "BTCUSDT");
1458        assert_eq!(streamed.timeframe, "4h");
1459    }
1460
1461    #[test]
1462    fn a_streaming_bar_without_its_required_feed_is_rejected() {
1463        // The batch entry points check the whole run's feeds once, up front. A
1464        // streaming caller has no "up front", so the same standard has to be
1465        // applied per bar -- otherwise `step()` would price a spread-slippage
1466        // spec at zero slippage, reporting a cheaper strategy than the one asked
1467        // for, which is exactly what the batch check exists to prevent.
1468        let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
1469        let candles = oscillating(10);
1470
1471        let mut blind = StreamingBacktest::new(&spec, 10_000.0).unwrap();
1472        let err = blind.step(&candles[0]).unwrap_err();
1473        let BacktestError::InvalidSpec(msg) = err else {
1474            panic!("expected InvalidSpec, got {err:?}");
1475        };
1476        assert!(
1477            msg.contains("order-book"),
1478            "message should say what is missing: {msg}"
1479        );
1480
1481        // The same spec, fed a book each bar, runs -- so the rejection is about
1482        // the feed, not about the spec.
1483        let mut fed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
1484        for candle in &candles {
1485            let book = OrderBook {
1486                bids: vec![Level {
1487                    price: candle.close - 0.01,
1488                    size: 1.0,
1489                }],
1490                asks: vec![Level {
1491                    price: candle.close + 0.01,
1492                    size: 1.0,
1493                }],
1494            };
1495            let feeds = Feeds {
1496                orderbook: Some(&book),
1497                ..Feeds::default()
1498            };
1499            fed.step_with_feeds(candle, &feeds).unwrap();
1500        }
1501        assert_eq!(fed.equity().len(), candles.len());
1502    }
1503
1504    #[test]
1505    fn spread_slippage_without_an_order_book_is_rejected() {
1506        let spec = spec_with(r#"{"slippage":{"type":"spread"}}"#);
1507        let candles = oscillating(60);
1508        let err = run(&spec, &candles).unwrap_err();
1509        let BacktestError::InvalidSpec(msg) = err else {
1510            panic!("expected InvalidSpec, got {err:?}");
1511        };
1512        assert!(
1513            msg.contains("order-book"),
1514            "message should say what is missing: {msg}"
1515        );
1516
1517        // The same spec with a book runs, which is what makes the rejection a
1518        // statement about the feed rather than about the spec.
1519        let books: Vec<OrderBook> = candles
1520            .iter()
1521            .map(|c| OrderBook {
1522                bids: vec![Level {
1523                    price: c.close - 0.01,
1524                    size: 1.0,
1525                }],
1526                asks: vec![Level {
1527                    price: c.close + 0.01,
1528                    size: 1.0,
1529                }],
1530            })
1531            .collect();
1532        assert!(run_with_orderbook(&spec, &candles, &books, DEFAULT_CAPITAL).is_ok());
1533    }
1534
1535    #[test]
1536    fn funding_without_a_derivatives_feed_is_rejected() {
1537        let spec = spec_with(r#"{"funding":true}"#);
1538        let candles = oscillating(60);
1539        let err = run(&spec, &candles).unwrap_err();
1540        let BacktestError::InvalidSpec(msg) = err else {
1541            panic!("expected InvalidSpec, got {err:?}");
1542        };
1543        assert!(
1544            msg.contains("derivatives"),
1545            "message should say what is missing: {msg}"
1546        );
1547    }
1548
1549    #[test]
1550    fn a_spec_that_prices_nothing_special_needs_no_extra_feed() {
1551        // The guard must not reject the ordinary case: fixed-bps slippage and no
1552        // funding run over plain candles.
1553        let spec = spec_with(r#"{"slippage":{"type":"fixed_bps","bps":1.0}}"#);
1554        assert!(run(&spec, &oscillating(60)).is_ok());
1555    }
1556
1557    /// A generated indicator — one that was never in the original hand-written
1558    /// registry — drives a full backtest, proving the expanded registry
1559    /// integrates end to end through the engine.
1560    #[test]
1561    fn generated_indicator_drives_backtest() {
1562        // `Alma` is one of the generated scalar (`Input = f64`) indicators.
1563        let spec = StrategySpec::parse(
1564            r#"{"symbol":"x","timeframe":"1h",
1565                "indicators":{"a":{"type":"Alma","params":[9,0.85,6.0]}},
1566                "entry":{"cross_above":[{"price":"close"},"a"]},
1567                "exit":{"cross_below":[{"price":"close"},"a"]},
1568                "sizing":{"type":"fixed_qty","qty":1}}"#,
1569        )
1570        .unwrap();
1571        let candles: Vec<Candle> = (0..60)
1572            .map(|i| {
1573                let px = 100.0 + ((i as f64) * 0.4).sin() * 6.0;
1574                bar(i, px, px + 0.5, px - 0.5, px)
1575            })
1576            .collect();
1577        let r = run(&spec, &candles).unwrap();
1578        // It ran over every bar and produced a full equity curve.
1579        assert_eq!(r.equity.len(), candles.len());
1580        // The oscillating series crosses the moving average, so it trades.
1581        assert!(r.metrics.num_trades >= 1);
1582    }
1583
1584    /// A price-threshold long strategy with no costs, hand-computed end to end.
1585    #[test]
1586    fn hand_computed_round_trip() {
1587        let spec = StrategySpec::parse(
1588            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1589                "entry":{"gt":[{"price":"close"},100]},
1590                "exit":{"lt":[{"price":"close"},100]},
1591                "sizing":{"type":"fixed_qty","qty":1}}"#,
1592        )
1593        .unwrap();
1594        let candles = [
1595            bar(0, 100.0, 101.0, 100.0, 101.0),
1596            bar(1, 102.0, 103.0, 102.0, 103.0), // fill enter @ open 102
1597            bar(2, 104.0, 104.0, 99.0, 99.0),
1598            bar(3, 98.0, 98.0, 97.0, 97.0), // fill exit @ open 98
1599        ];
1600        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1601        assert_eq!(r.trades.len(), 1);
1602        let t = &r.trades[0];
1603        assert!((t.entry_price - 102.0).abs() < 1e-9);
1604        assert!((t.exit_price - 98.0).abs() < 1e-9);
1605        assert!((t.pnl - (-4.0)).abs() < 1e-9);
1606        assert!((r.equity.last().unwrap().equity - 996.0).abs() < 1e-9);
1607    }
1608
1609    /// Short entry profits when price falls; exit fills at next open.
1610    #[test]
1611    fn short_round_trip() {
1612        let spec = StrategySpec::parse(
1613            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1614                "entry":{"lt":[{"price":"close"},0]},
1615                "exit":{"in_position":true},
1616                "short_entry":{"lt":[{"price":"close"},100]},
1617                "short_exit":{"gt":[{"price":"close"},100]},
1618                "sizing":{"type":"fixed_qty","qty":1}}"#,
1619        )
1620        .unwrap();
1621        let candles = [
1622            bar(0, 100.0, 100.0, 99.0, 99.0),   // close 99 < 100 -> short signal
1623            bar(1, 98.0, 98.0, 98.0, 98.0),     // fill short @ open 98
1624            bar(2, 101.0, 101.0, 101.0, 101.0), // close 101 > 100 -> cover signal
1625            bar(3, 102.0, 102.0, 102.0, 102.0), // fill cover @ open 102
1626        ];
1627        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1628        assert_eq!(r.trades.len(), 1);
1629        let t = &r.trades[0];
1630        assert!((t.entry_price - 98.0).abs() < 1e-9);
1631        assert!((t.exit_price - 102.0).abs() < 1e-9);
1632        // short pnl = -1 * (102 - 98) = -4
1633        assert!((t.pnl - (-4.0)).abs() < 1e-9);
1634        assert_eq!(t.reason, "signal");
1635    }
1636
1637    /// A long position whose stop is hit intrabar fills at the stop level.
1638    #[test]
1639    fn intrabar_stop_loss() {
1640        let spec = StrategySpec::parse(
1641            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1642                "entry":{"gt":[{"price":"close"},0]},
1643                "exit":{"lt":[{"price":"close"},0]},
1644                "sizing":{"type":"fixed_qty","qty":1},
1645                "risk":{"stop_loss_pct":5.0}}"#,
1646        )
1647        .unwrap();
1648        let candles = [
1649            bar(0, 100.0, 100.0, 100.0, 100.0), // enter signal
1650            bar(1, 100.0, 101.0, 100.0, 100.0), // fill enter @ 100; stop at 95
1651            bar(2, 99.0, 99.0, 90.0, 92.0),     // low 90 <= 95 -> stop fills @ 95
1652        ];
1653        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1654        assert_eq!(r.trades.len(), 1);
1655        let t = &r.trades[0];
1656        assert!((t.exit_price - 95.0).abs() < 1e-9);
1657        assert_eq!(t.reason, "stop_loss");
1658        assert!((t.pnl - (-5.0)).abs() < 1e-9); // 1 * (95 - 100)
1659    }
1660
1661    /// A long position whose target is hit intrabar fills at the target level.
1662    #[test]
1663    fn intrabar_take_profit() {
1664        let spec = StrategySpec::parse(
1665            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1666                "entry":{"gt":[{"price":"close"},0]},
1667                "exit":{"lt":[{"price":"close"},0]},
1668                "sizing":{"type":"fixed_qty","qty":1},
1669                "risk":{"take_profit_pct":10.0}}"#,
1670        )
1671        .unwrap();
1672        let candles = [
1673            bar(0, 100.0, 100.0, 100.0, 100.0), // enter signal
1674            bar(1, 100.0, 100.0, 100.0, 100.0), // fill enter @ 100; target 110
1675            bar(2, 105.0, 115.0, 105.0, 112.0), // high 115 >= 110 -> target fills @ 110
1676        ];
1677        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1678        assert_eq!(r.trades.len(), 1);
1679        let t = &r.trades[0];
1680        assert!((t.exit_price - 110.0).abs() < 1e-9);
1681        assert_eq!(t.reason, "take_profit");
1682        assert!((t.pnl - 10.0).abs() < 1e-9);
1683    }
1684
1685    /// When a single bar's range spans both the stop and the target, the stop
1686    /// is assumed hit first (the conservative O→H→L→C path): the exit is the
1687    /// stop, not the target.
1688    #[test]
1689    fn simultaneous_stop_and_target_prefers_stop() {
1690        let spec = StrategySpec::parse(
1691            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1692                "entry":{"gt":[{"price":"close"},0]},
1693                "exit":{"lt":[{"price":"close"},0]},
1694                "sizing":{"type":"fixed_qty","qty":1},
1695                "risk":{"stop_loss_pct":5.0,"take_profit_pct":10.0}}"#,
1696        )
1697        .unwrap();
1698        let candles = [
1699            bar(0, 100.0, 100.0, 100.0, 100.0), // enter signal
1700            bar(1, 100.0, 100.0, 100.0, 100.0), // fill enter @ 100; stop 95, target 110
1701            bar(2, 100.0, 115.0, 90.0, 100.0),  // range hits BOTH 90<=95 and 115>=110
1702        ];
1703        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1704        assert_eq!(r.trades.len(), 1);
1705        let t = &r.trades[0];
1706        assert_eq!(t.reason, "stop_loss");
1707        assert!((t.exit_price - 95.0).abs() < 1e-9);
1708        assert!((t.pnl - (-5.0)).abs() < 1e-9);
1709    }
1710
1711    /// A bar that gaps entirely below the stop still triggers it, and fills at
1712    /// the gapped-down open (the realistic, conservative price) — not the
1713    /// unreachable stop level, which the bar never traded at.
1714    #[test]
1715    fn gap_down_through_stop_fills_at_open() {
1716        let spec = StrategySpec::parse(
1717            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1718                "entry":{"gt":[{"price":"close"},0]},
1719                "exit":{"lt":[{"price":"close"},0]},
1720                "sizing":{"type":"fixed_qty","qty":1},
1721                "risk":{"stop_loss_pct":5.0}}"#,
1722        )
1723        .unwrap();
1724        let candles = [
1725            bar(0, 100.0, 100.0, 100.0, 100.0), // enter signal
1726            bar(1, 100.0, 100.0, 100.0, 100.0), // fill enter @ 100; stop at 95
1727            bar(2, 90.0, 92.0, 88.0, 89.0),     // gaps open 90, below the 95 stop
1728        ];
1729        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1730        assert_eq!(r.trades.len(), 1);
1731        let t = &r.trades[0];
1732        assert_eq!(t.reason, "stop_loss");
1733        assert!((t.exit_price - 90.0).abs() < 1e-9); // the gapped open, not 95
1734        assert!((t.pnl - (-10.0)).abs() < 1e-9); // 1 * (90 - 100)
1735    }
1736
1737    /// A short whose stop gaps up: fills at the gapped-up open (worse for the
1738    /// short), not the lower stop level.
1739    #[test]
1740    fn gap_up_through_short_stop_fills_at_open() {
1741        let spec = StrategySpec::parse(
1742            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1743                "entry":{"lt":[{"price":"close"},0]},"exit":{"in_position":false},
1744                "short_entry":{"gt":[{"price":"close"},0]},
1745                "short_exit":{"lt":[{"price":"close"},0]},
1746                "sizing":{"type":"fixed_qty","qty":1},
1747                "risk":{"stop_loss_pct":5.0}}"#,
1748        )
1749        .unwrap();
1750        let candles = [
1751            bar(0, 100.0, 100.0, 100.0, 100.0), // short signal
1752            bar(1, 100.0, 100.0, 100.0, 100.0), // fill short @ 100; stop at 105
1753            bar(2, 110.0, 112.0, 108.0, 111.0), // gaps open 110, above the 105 stop
1754        ];
1755        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1756        assert_eq!(r.trades.len(), 1);
1757        let t = &r.trades[0];
1758        assert_eq!(t.reason, "stop_loss");
1759        assert!((t.exit_price - 110.0).abs() < 1e-9); // the gapped open, not 105
1760    }
1761
1762    /// A long trailing stop exits when price retraces past the trailed peak.
1763    #[test]
1764    fn trailing_stop() {
1765        let spec = StrategySpec::parse(
1766            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1767                "entry":{"gt":[{"price":"close"},0]},
1768                "exit":{"lt":[{"price":"close"},0]},
1769                "sizing":{"type":"fixed_qty","qty":1},
1770                "risk":{"trailing_stop_pct":10.0}}"#,
1771        )
1772        .unwrap();
1773        let candles = [
1774            bar(0, 100.0, 100.0, 100.0, 100.0), // enter signal
1775            bar(1, 100.0, 100.0, 100.0, 100.0), // fill enter @ 100
1776            bar(2, 100.0, 120.0, 119.0, 120.0), // peak 120 (trail 108, low 119 -> no exit)
1777            bar(3, 118.0, 118.0, 105.0, 106.0), // low 105 <= 108 -> trailing fills @ 108
1778        ];
1779        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1780        assert_eq!(r.trades.len(), 1);
1781        let t = &r.trades[0];
1782        assert_eq!(t.reason, "trailing_stop");
1783        assert!((t.exit_price - 108.0).abs() < 1e-9);
1784        assert!((t.pnl - 8.0).abs() < 1e-9);
1785    }
1786
1787    #[test]
1788    fn no_signals_no_trades() {
1789        let spec = StrategySpec::parse(
1790            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1791                "entry":{"gt":[{"price":"close"},1000000]},
1792                "exit":{"in_position":true},
1793                "sizing":{"type":"fixed_qty","qty":1}}"#,
1794        )
1795        .unwrap();
1796        let candles = [
1797            bar(0, 10.0, 10.0, 10.0, 10.0),
1798            bar(1, 11.0, 11.0, 11.0, 11.0),
1799        ];
1800        let r = run(&spec, &candles).unwrap();
1801        assert!(r.trades.is_empty());
1802    }
1803
1804    #[test]
1805    fn open_position_closed_at_end() {
1806        let spec = StrategySpec::parse(
1807            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1808                "entry":{"gt":[{"price":"close"},0]},
1809                "exit":{"lt":[{"price":"close"},0]},
1810                "sizing":{"type":"fixed_qty","qty":1}}"#,
1811        )
1812        .unwrap();
1813        let candles = [
1814            bar(0, 10.0, 10.0, 10.0, 10.0),
1815            bar(1, 11.0, 11.0, 11.0, 11.0),
1816        ];
1817        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
1818        assert_eq!(r.trades.len(), 1);
1819        assert_eq!(r.trades[0].reason, "end");
1820    }
1821
1822    #[test]
1823    fn sma_crossover_runs() {
1824        let spec = StrategySpec::parse(
1825            r#"{"symbol":"x","timeframe":"1h",
1826                "indicators":{"fast":{"type":"Sma","params":[2]},"slow":{"type":"Sma","params":[3]}},
1827                "entry":{"cross_above":["fast","slow"]},
1828                "exit":{"cross_below":["fast","slow"]},
1829                "sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
1830        )
1831        .unwrap();
1832        let candles: Vec<Candle> = (0..20)
1833            .map(|i| {
1834                bar(
1835                    i,
1836                    100.0 + i as f64,
1837                    100.0 + i as f64,
1838                    100.0,
1839                    100.0 + i as f64,
1840                )
1841            })
1842            .collect();
1843        let r = run(&spec, &candles).unwrap();
1844        assert_eq!(r.equity.len(), 20);
1845        assert_eq!(r.schema_version, REPORT_SCHEMA_VERSION);
1846    }
1847
1848    /// A multi-output indicator referenced by field (`bb.upper` / `bb.lower`)
1849    /// resolves end to end through the engine.
1850    #[test]
1851    fn multi_output_field_ref_runs() {
1852        let spec = StrategySpec::parse(
1853            r#"{"symbol":"x","timeframe":"1h",
1854                "indicators":{"bb":{"type":"Bollinger","params":[5,2]}},
1855                "entry":{"gt":[{"price":"close"},"bb.upper"]},
1856                "exit":{"lt":[{"price":"close"},"bb.lower"]},
1857                "sizing":{"type":"fixed_fraction","fraction":0.5}}"#,
1858        )
1859        .unwrap();
1860        let candles: Vec<Candle> = (0..30)
1861            .map(|i| {
1862                let p = 100.0 + (i as f64 * 0.5).sin() * 5.0;
1863                bar(i, p, p + 1.0, p - 1.0, p)
1864            })
1865            .collect();
1866        let r = run(&spec, &candles).unwrap();
1867        assert_eq!(r.equity.len(), 30);
1868    }
1869
1870    #[test]
1871    fn vol_target_sizes_inversely_to_vol() {
1872        // target 1% per bar, realized 2% => notional 0.5x equity => 50 units.
1873        let q = size(
1874            Sizing::VolTarget {
1875                target_vol: 0.01,
1876                lookback: 5,
1877            },
1878            &Risk::default(),
1879            10_000.0,
1880            100.0,
1881            Some(0.02),
1882        )
1883        .unwrap()
1884        .unwrap();
1885        assert!((q - 50.0).abs() < 1e-9);
1886    }
1887
1888    #[test]
1889    fn vol_target_takes_no_position_without_history() {
1890        let none = size(
1891            Sizing::VolTarget {
1892                target_vol: 0.01,
1893                lookback: 5,
1894            },
1895            &Risk::default(),
1896            10_000.0,
1897            100.0,
1898            None,
1899        )
1900        .unwrap();
1901        assert!(none.is_none());
1902    }
1903
1904    #[test]
1905    fn vol_target_trades_after_warmup() {
1906        let spec = StrategySpec::parse(
1907            r#"{"symbol":"x","timeframe":"1h","indicators":{},
1908                "entry":{"gt":[{"price":"close"},0]},
1909                "exit":{"in_position":false},
1910                "sizing":{"type":"vol_target","target_vol":0.02,"lookback":3}}"#,
1911        )
1912        .unwrap();
1913        let closes = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0];
1914        let candles: Vec<Candle> = closes
1915            .iter()
1916            .enumerate()
1917            .map(|(i, &c)| bar(i64::try_from(i).unwrap(), c, c + 0.5, c - 0.5, c))
1918            .collect();
1919        let r = run(&spec, &candles).unwrap();
1920        // Once `lookback` bars of history exist, a vol-targeted position is taken.
1921        assert!(!r.trades.is_empty());
1922        assert!(r.trades[0].qty > 0.0);
1923    }
1924
1925    #[test]
1926    fn risk_per_trade_sizes_from_stop() {
1927        // equity 10_000, risk 1% = 100 cash; stop 2% of price 100 = 2 per unit
1928        // => 50 units (notional 5_000, under the 1x cap).
1929        let risk = Risk {
1930            stop_loss_pct: Some(2.0),
1931            ..Default::default()
1932        };
1933        let q = size(
1934            Sizing::RiskPerTrade { risk_pct: 1.0 },
1935            &risk,
1936            10_000.0,
1937            100.0,
1938            None,
1939        )
1940        .unwrap()
1941        .unwrap();
1942        assert!((q - 50.0).abs() < 1e-9);
1943    }
1944
1945    #[test]
1946    fn risk_per_trade_requires_stop() {
1947        assert!(size(
1948            Sizing::RiskPerTrade { risk_pct: 1.0 },
1949            &Risk::default(),
1950            10_000.0,
1951            100.0,
1952            None
1953        )
1954        .is_err());
1955    }
1956
1957    #[test]
1958    fn default_leverage_caps_at_equity() {
1959        // fixed_cash 50_000 but equity 10_000 and no max_leverage => capped to 1x.
1960        let q = size(
1961            Sizing::FixedCash { cash: 50_000.0 },
1962            &Risk::default(),
1963            10_000.0,
1964            100.0,
1965            None,
1966        )
1967        .unwrap()
1968        .unwrap();
1969        assert!((q - 100.0).abs() < 1e-9);
1970    }
1971
1972    #[test]
1973    fn max_leverage_allows_more_than_equity() {
1974        let risk = Risk {
1975            max_leverage: Some(3.0),
1976            ..Default::default()
1977        };
1978        let q = size(
1979            Sizing::FixedCash { cash: 50_000.0 },
1980            &risk,
1981            10_000.0,
1982            100.0,
1983            None,
1984        )
1985        .unwrap()
1986        .unwrap();
1987        assert!((q - 300.0).abs() < 1e-9); // 3x equity / price
1988    }
1989
1990    #[test]
1991    fn max_position_pct_caps_notional() {
1992        let risk = Risk {
1993            max_leverage: Some(5.0),
1994            max_position_pct: Some(20.0),
1995            ..Default::default()
1996        };
1997        // 5x would allow 50_000, but 20% of equity = 2_000 notional => 20 units.
1998        let q = size(
1999            Sizing::FixedCash { cash: 50_000.0 },
2000            &risk,
2001            10_000.0,
2002            100.0,
2003            None,
2004        )
2005        .unwrap()
2006        .unwrap();
2007        assert!((q - 20.0).abs() < 1e-9);
2008    }
2009
2010    #[test]
2011    fn leverage_flows_through_run() {
2012        let candles = [
2013            bar(0, 100.0, 100.0, 100.0, 100.0),
2014            bar(1, 100.0, 100.0, 100.0, 100.0), // enter @ open 100
2015            bar(2, 100.0, 100.0, 100.0, 100.0),
2016        ];
2017        let no_lev = StrategySpec::parse(
2018            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2019                "entry":{"gt":[{"price":"close"},0]},
2020                "exit":{"in_position":false},
2021                "sizing":{"type":"fixed_cash","cash":50000}}"#,
2022        )
2023        .unwrap();
2024        let r0 = run_with_capital(&no_lev, &candles, 10_000.0).unwrap();
2025        assert!((r0.trades[0].qty - 100.0).abs() < 1e-9); // capped to 1x equity
2026
2027        let levered = StrategySpec::parse(
2028            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2029                "entry":{"gt":[{"price":"close"},0]},
2030                "exit":{"in_position":false},
2031                "sizing":{"type":"fixed_cash","cash":50000},
2032                "risk":{"max_leverage":3}}"#,
2033        )
2034        .unwrap();
2035        let r1 = run_with_capital(&levered, &candles, 10_000.0).unwrap();
2036        assert!((r1.trades[0].qty - 300.0).abs() < 1e-9); // 3x equity
2037    }
2038
2039    #[test]
2040    fn limit_entry_fills_on_dip() {
2041        let spec = StrategySpec::parse(
2042            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2043                "entry":{"gt":[{"price":"close"},0]},
2044                "exit":{"in_position":false},
2045                "sizing":{"type":"fixed_qty","qty":1},
2046                "execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
2047        )
2048        .unwrap();
2049        let candles = [
2050            bar(0, 100.0, 100.0, 100.0, 100.0), // signal -> limit works @ 99
2051            bar(1, 100.0, 101.0, 100.0, 100.0), // low 100 > 99: no fill, keeps working
2052            bar(2, 100.0, 100.0, 98.0, 99.0),   // low 98 <= 99: fills @ 99
2053        ];
2054        let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2055        assert_eq!(r.trades.len(), 1);
2056        assert!((r.trades[0].entry_price - 99.0).abs() < 1e-9);
2057    }
2058
2059    #[test]
2060    fn limit_entry_never_fills_without_a_dip() {
2061        let spec = StrategySpec::parse(
2062            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2063                "entry":{"gt":[{"price":"close"},0]},
2064                "exit":{"in_position":false},
2065                "sizing":{"type":"fixed_qty","qty":1},
2066                "execution":{"order_type":"limit","limit_offset_pct":-1.0}}"#,
2067        )
2068        .unwrap();
2069        let candles = [
2070            bar(0, 100.0, 100.0, 100.0, 100.0),
2071            bar(1, 100.0, 101.0, 100.0, 100.0),
2072            bar(2, 100.0, 102.0, 100.0, 101.0), // low never reaches 99
2073        ];
2074        let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2075        assert!(r.trades.is_empty());
2076    }
2077
2078    #[test]
2079    fn stop_entry_fills_on_breakout() {
2080        let spec = StrategySpec::parse(
2081            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2082                "entry":{"gt":[{"price":"close"},0]},
2083                "exit":{"in_position":false},
2084                "sizing":{"type":"fixed_qty","qty":1},
2085                "execution":{"order_type":"stop","stop_offset_pct":1.0}}"#,
2086        )
2087        .unwrap();
2088        let candles = [
2089            bar(0, 100.0, 100.0, 100.0, 100.0), // signal -> stop works @ 101
2090            bar(1, 100.0, 100.5, 100.0, 100.0), // high 100.5 < 101: no fill
2091            bar(2, 100.0, 102.0, 100.0, 101.0), // high 102 >= 101: fills @ 101
2092        ];
2093        let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2094        assert_eq!(r.trades.len(), 1);
2095        assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
2096    }
2097
2098    #[test]
2099    fn limit_order_requires_offset() {
2100        // `parse` validates, so an order_type without its offset is rejected up front.
2101        assert!(StrategySpec::parse(
2102            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2103                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2104                "sizing":{"type":"fixed_qty","qty":1},
2105                "execution":{"order_type":"limit"}}"#,
2106        )
2107        .is_err());
2108    }
2109
2110    #[test]
2111    fn stop_limit_is_unsupported() {
2112        assert!(StrategySpec::parse(
2113            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2114                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2115                "sizing":{"type":"fixed_qty","qty":1},
2116                "execution":{"order_type":"stop_limit"}}"#,
2117        )
2118        .is_err());
2119    }
2120
2121    #[test]
2122    fn latency_delays_the_fill() {
2123        let spec = StrategySpec::parse(
2124            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2125                "entry":{"gt":[{"price":"close"},0]},
2126                "exit":{"in_position":false},
2127                "sizing":{"type":"fixed_qty","qty":1},
2128                "execution":{"latency_bars":1}}"#,
2129        )
2130        .unwrap();
2131        let candles = [
2132            bar(0, 100.0, 100.0, 100.0, 100.0), // signal at close
2133            bar(1, 110.0, 110.0, 110.0, 110.0), // would fill here without latency
2134            bar(2, 120.0, 120.0, 120.0, 120.0), // fills here after 1 bar of latency
2135        ];
2136        let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2137        assert_eq!(r.trades.len(), 1);
2138        assert!((r.trades[0].entry_price - 120.0).abs() < 1e-9);
2139    }
2140
2141    #[test]
2142    fn partial_fills_cap_entry_to_participation() {
2143        let spec = StrategySpec::parse(
2144            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2145                "entry":{"gt":[{"price":"close"},0]},
2146                "exit":{"in_position":false},
2147                "sizing":{"type":"fixed_qty","qty":100},
2148                "execution":{"partial_fills":true,"max_participation":0.05}}"#,
2149        )
2150        .unwrap();
2151        // The fill bar's volume is 1000, so the cap is 0.05 * 1000 = 50 units,
2152        // below the desired 100.
2153        let vbar = |time, volume| Candle {
2154            time,
2155            open: 100.0,
2156            high: 100.0,
2157            low: 100.0,
2158            close: 100.0,
2159            volume,
2160        };
2161        let candles = [vbar(0, 0.0), vbar(1, 1000.0), vbar(2, 1000.0)];
2162        let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
2163        assert_eq!(r.trades.len(), 1);
2164        assert!((r.trades[0].qty - 50.0).abs() < 1e-9);
2165    }
2166
2167    #[test]
2168    fn partial_fills_requires_participation() {
2169        assert!(StrategySpec::parse(
2170            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2171                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2172                "sizing":{"type":"fixed_qty","qty":1},
2173                "execution":{"partial_fills":true}}"#,
2174        )
2175        .is_err());
2176    }
2177
2178    #[test]
2179    fn fill_timing_close_fills_same_bar() {
2180        let spec = StrategySpec::parse(
2181            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2182                "entry":{"gt":[{"price":"close"},100]},
2183                "exit":{"lt":[{"price":"close"},100]},
2184                "sizing":{"type":"fixed_qty","qty":1},
2185                "execution":{"fill_timing":"close"}}"#,
2186        )
2187        .unwrap();
2188        let candles = [
2189            bar(0, 90.0, 90.0, 90.0, 90.0),   // close 90: no entry
2190            bar(1, 95.0, 105.0, 95.0, 101.0), // close 101 > 100: entry @ close 101
2191            bar(2, 100.0, 100.0, 90.0, 99.0), // close 99 < 100: exit @ close 99
2192        ];
2193        let r = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2194        assert_eq!(r.trades.len(), 1);
2195        assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9); // same-bar close
2196        assert!((r.trades[0].exit_price - 99.0).abs() < 1e-9);
2197    }
2198
2199    #[test]
2200    fn fill_timing_close_rejects_limit_and_latency() {
2201        // Close fills can't express the next-bar limit/stop or latency models.
2202        assert!(StrategySpec::parse(
2203            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2204                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2205                "sizing":{"type":"fixed_qty","qty":1},
2206                "execution":{"fill_timing":"close","order_type":"limit","limit_offset_pct":-1.0}}"#,
2207        )
2208        .is_err());
2209        assert!(StrategySpec::parse(
2210            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2211                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2212                "sizing":{"type":"fixed_qty","qty":1},
2213                "execution":{"fill_timing":"close","latency_bars":1}}"#,
2214        )
2215        .is_err());
2216    }
2217
2218    #[test]
2219    fn history_depth_covers_every_backward_looking_form() {
2220        // A cross reaches one bar back; `prev` compounds; `rising`/`falling` take
2221        // their own count. The window has to be the maximum of all of them, plus
2222        // the current bar.
2223        let depth = |rules: &str| {
2224            let spec = StrategySpec::parse(&format!(
2225                r#"{{"symbol":"x","timeframe":"1h","indicators":{{}},{rules},
2226                    "sizing":{{"type":"fixed_qty","qty":1}}}}"#
2227            ))
2228            .unwrap();
2229            history_depth(&spec)
2230        };
2231
2232        // Plain comparisons read only the current bar.
2233        assert_eq!(
2234            depth(r#""entry":{"gt":[{"price":"close"},1]},"exit":{"lt":[{"price":"close"},1]}"#),
2235            1
2236        );
2237        // A cross compares this bar with the previous one.
2238        assert_eq!(
2239            depth(
2240                r#""entry":{"cross_above":[{"price":"close"},{"price":"open"}]},
2241                   "exit":{"lt":[{"price":"close"},1]}"#
2242            ),
2243            2
2244        );
2245        // `rising` by n reaches n bars back.
2246        assert_eq!(
2247            depth(
2248                r#""entry":{"rising":[{"price":"close"},9]},"exit":{"lt":[{"price":"close"},1]}"#
2249            ),
2250            10
2251        );
2252        // Nested `prev` compounds, and the deepest rule wins.
2253        assert_eq!(
2254            depth(
2255                r#""entry":{"gt":[{"prev":[{"prev":[{"price":"close"},2]},3]},1]},
2256                   "exit":{"lt":[{"price":"close"},1]}"#
2257            ),
2258            6
2259        );
2260        // Vol targeting reads the last `lookback` closes off the tail.
2261        let vol = StrategySpec::parse(
2262            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2263                "entry":{"gt":[{"price":"close"},1]},
2264                "exit":{"lt":[{"price":"close"},1]},
2265                "sizing":{"type":"vol_target","target_vol":0.02,"lookback":20}}"#,
2266        )
2267        .unwrap();
2268        assert_eq!(history_depth(&vol), 21);
2269    }
2270
2271    #[test]
2272    fn history_stays_bounded_over_a_long_run() {
2273        // The claim this guards: a run that never ends must not grow without end.
2274        // Before the window, a year of minute bars retained a year of rows.
2275        let spec = StrategySpec::parse(
2276            r#"{"symbol":"x","timeframe":"1h","indicators":{"f":{"type":"Ema","params":[3]}},
2277                "entry":{"cross_above":[{"price":"close"},"f"]},
2278                "exit":{"cross_below":[{"price":"close"},"f"]},
2279                "sizing":{"type":"fixed_qty","qty":1}}"#,
2280        )
2281        .unwrap();
2282        let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2283        for i in 0..20_000i64 {
2284            let px = 100.0 + ((i as f64) * 0.05).sin() * 5.0;
2285            bt.step(&bar(i, px, px + 0.5, px - 0.5, px)).unwrap();
2286        }
2287        assert_eq!(bt.bars_seen, 20_000);
2288        assert_eq!(bt.history_depth, 2);
2289        assert_eq!(bt.history.len(), 2);
2290        // It still traded, so the bounded window did not blind the rules.
2291        assert!(bt.num_trades() > 0);
2292    }
2293
2294    #[test]
2295    fn a_deep_lookback_still_sees_far_enough() {
2296        // `rising` by 40 must keep working after the window has filled and started
2297        // evicting: the retained depth is what the rule reaches, not less.
2298        let spec = StrategySpec::parse(
2299            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2300                "entry":{"rising":[{"price":"close"},40]},
2301                "exit":{"falling":[{"price":"close"},40]},
2302                "sizing":{"type":"fixed_qty","qty":1}}"#,
2303        )
2304        .unwrap();
2305        assert_eq!(history_depth(&spec), 41);
2306
2307        let candles: Vec<Candle> = (0..400i64)
2308            .map(|i| {
2309                let px = 100.0 + ((i as f64) * 0.03).sin() * 10.0;
2310                bar(i, px, px + 0.5, px - 0.5, px)
2311            })
2312            .collect();
2313        let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2314        assert!(batch.metrics.num_trades >= 1, "the fixture must trade");
2315
2316        let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2317        for candle in &candles {
2318            bt.step(candle).unwrap();
2319        }
2320        assert_eq!(bt.history.len(), 41);
2321        let streamed = bt.finish();
2322        assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
2323        assert!((streamed.metrics.pnl - batch.metrics.pnl).abs() < 1e-9);
2324    }
2325
2326    #[test]
2327    fn streaming_matches_batch() {
2328        // Feeding bars one at a time through the public streaming API produces
2329        // the same report as the batch runner — backtest and live are one path.
2330        let spec = StrategySpec::parse(
2331            r#"{"symbol":"x","timeframe":"1h",
2332                "indicators":{"f":{"type":"Ema","params":[3]}},
2333                "entry":{"cross_above":[{"price":"close"},"f"]},
2334                "exit":{"cross_below":[{"price":"close"},"f"]},
2335                "sizing":{"type":"fixed_qty","qty":1}}"#,
2336        )
2337        .unwrap();
2338        let candles: Vec<Candle> = (0..30i64)
2339            .map(|i| {
2340                let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
2341                bar(i, px, px + 0.5, px - 0.5, px)
2342            })
2343            .collect();
2344
2345        let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2346
2347        let mut bt = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2348        for c in &candles {
2349            bt.step(c).unwrap();
2350        }
2351        let streamed = bt.finish();
2352
2353        assert!(batch.metrics.num_trades >= 1);
2354        assert_eq!(batch.metrics.num_trades, streamed.metrics.num_trades);
2355        assert_eq!(batch.trades.len(), streamed.trades.len());
2356        assert_eq!(batch.equity.len(), streamed.equity.len());
2357        assert!(
2358            (batch.equity.last().unwrap().equity - streamed.equity.last().unwrap().equity).abs()
2359                < 1e-12
2360        );
2361    }
2362
2363    #[test]
2364    fn run_stream_matches_batch_and_tails_equity() {
2365        // The streaming entry point yields a byte-identical report to the batch
2366        // runner, and the per-bar hook sees the equity curve grow one point at a
2367        // time — the live-tail path.
2368        let spec = StrategySpec::parse(
2369            r#"{"symbol":"x","timeframe":"1h",
2370                "indicators":{"f":{"type":"Ema","params":[3]}},
2371                "entry":{"cross_above":[{"price":"close"},"f"]},
2372                "exit":{"cross_below":[{"price":"close"},"f"]},
2373                "sizing":{"type":"fixed_qty","qty":1}}"#,
2374        )
2375        .unwrap();
2376        let candles: Vec<Candle> = (0..30i64)
2377            .map(|i| {
2378                let px = 100.0 + ((i as f64) * 0.5).sin() * 5.0;
2379                bar(i, px, px + 0.5, px - 0.5, px)
2380            })
2381            .collect();
2382
2383        let batch = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2384
2385        let mut tail: Vec<EquityPoint> = Vec::new();
2386        let streamed = run_stream(&spec, &candles, 10_000.0, |i, bt| {
2387            // The equity curve has exactly one point per processed bar.
2388            assert_eq!(bt.equity().len(), i + 1);
2389            tail.push(bt.latest_equity().expect("a bar was marked"));
2390        })
2391        .unwrap();
2392
2393        assert_eq!(tail.len(), candles.len());
2394        assert_eq!(streamed.equity.len(), batch.equity.len());
2395        // The tailed points equal the report's equity series exactly.
2396        for (got, want) in tail.iter().zip(&streamed.equity) {
2397            assert_eq!(got.time, want.time);
2398            assert!((got.equity - want.equity).abs() < 1e-12);
2399        }
2400        assert_eq!(streamed.metrics.num_trades, batch.metrics.num_trades);
2401    }
2402
2403    #[test]
2404    fn pairwise_indicator_uses_reference_series() {
2405        // A pairwise indicator (Pearson correlation) is fed the reference
2406        // series' close as its second input via run_with_ref.
2407        let spec = StrategySpec::parse(
2408            r#"{"symbol":"x","timeframe":"1h",
2409                "indicators":{"c":{"type":"PearsonCorrelation","params":[3]}},
2410                "entry":{"gt":["c",0.5]},
2411                "exit":{"lt":["c",-2.0]},
2412                "sizing":{"type":"fixed_qty","qty":1}}"#,
2413        )
2414        .unwrap();
2415        let primary: Vec<Candle> = [100.0, 101.0, 102.0, 101.0, 103.0, 102.0, 104.0, 103.0]
2416            .iter()
2417            .zip(0i64..)
2418            .map(|(&c, i)| bar(i, c, c + 0.5, c - 0.5, c))
2419            .collect();
2420        // A perfectly correlated reference series → correlation ~1 > 0.5 → entry.
2421        let reference: Vec<Candle> = [50.0, 50.5, 51.0, 50.5, 51.5, 51.0, 52.0, 51.5]
2422            .iter()
2423            .zip(0i64..)
2424            .map(|(&c, i)| bar(i, c, c + 0.2, c - 0.2, c))
2425            .collect();
2426
2427        let with_ref = run_with_ref(&spec, &primary, &reference, 10_000.0).unwrap();
2428        assert!(with_ref.metrics.num_trades >= 1);
2429
2430        // Without a reference series the pairwise indicator yields nothing, so
2431        // the entry condition never fires.
2432        let without_ref = run_with_capital(&spec, &primary, 10_000.0).unwrap();
2433        assert_eq!(without_ref.metrics.num_trades, 0);
2434    }
2435
2436    #[test]
2437    fn pairwise_multi_output_exposes_fields() {
2438        // A pairwise multi-output indicator exposes its named fields when fed a
2439        // reference value.
2440        let mut ind = registry::build("RelativeStrengthAB", &[3.0, 3.0]).unwrap();
2441        let mut names: Vec<&str> = Vec::new();
2442        let prices = [
2443            100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
2444        ];
2445        for (i, &px) in prices.iter().enumerate() {
2446            let c = Candle {
2447                time: i64::try_from(i).unwrap(),
2448                open: px,
2449                high: px,
2450                low: px,
2451                close: px,
2452                volume: 0.0,
2453            };
2454            let input = BarInput {
2455                candle: &c,
2456                reference: Some(px * 0.9),
2457                deriv: None,
2458                orderbook: None,
2459                trades: &[],
2460                cross_section: None,
2461            };
2462            if ind.update(&input).is_some() {
2463                names = ind.fields().iter().map(|(n, _)| *n).collect();
2464            }
2465        }
2466        assert!(names.contains(&"ratio"), "fields: {names:?}");
2467    }
2468
2469    #[test]
2470    fn run_with_ref_rejects_length_mismatch() {
2471        let spec = StrategySpec::parse(
2472            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2473                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2474                "sizing":{"type":"fixed_qty","qty":1}}"#,
2475        )
2476        .unwrap();
2477        let a = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
2478        let b = [bar(0, 1.0, 1.0, 1.0, 1.0)];
2479        assert!(run_with_ref(&spec, &a, &b, 10_000.0).is_err());
2480    }
2481
2482    fn sample_tick(funding_rate: f64) -> DerivativesTick {
2483        DerivativesTick {
2484            funding_rate,
2485            mark_price: 100.0,
2486            index_price: 100.0,
2487            futures_price: 100.0,
2488            open_interest: 1000.0,
2489            long_size: 600.0,
2490            short_size: 400.0,
2491            taker_buy_volume: 50.0,
2492            taker_sell_volume: 40.0,
2493            long_liquidation: 0.0,
2494            short_liquidation: 0.0,
2495            timestamp: 0,
2496        }
2497    }
2498
2499    #[test]
2500    fn derivatives_indicator_uses_feed() {
2501        // FundingRate passes the tick's funding rate through; the feed drives it.
2502        let spec = StrategySpec::parse(
2503            r#"{"symbol":"x","timeframe":"1h",
2504                "indicators":{"f":{"type":"FundingRate","params":[]}},
2505                "entry":{"gt":["f",0.0]},
2506                "exit":{"lt":["f",-1.0]},
2507                "sizing":{"type":"fixed_qty","qty":1}}"#,
2508        )
2509        .unwrap();
2510        let candles: Vec<Candle> = (0i64..5)
2511            .map(|i| bar(i, 100.0, 100.0, 100.0, 100.0))
2512            .collect();
2513        let derivs = vec![sample_tick(0.01); 5];
2514
2515        let with_feed = run_with_deriv(&spec, &candles, &derivs, 10_000.0).unwrap();
2516        assert!(with_feed.metrics.num_trades >= 1);
2517
2518        // Without a derivatives feed the indicator yields nothing → no entry.
2519        let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2520        assert_eq!(without_feed.metrics.num_trades, 0);
2521    }
2522
2523    #[test]
2524    fn order_book_indicator_uses_feed() {
2525        use crate::data::Level;
2526        let spec = StrategySpec::parse(
2527            r#"{"symbol":"x","timeframe":"1h",
2528                "indicators":{"i":{"type":"OrderBookImbalanceTop1","params":[]}},
2529                "entry":{"gt":["i",0.0]},
2530                "exit":{"lt":["i",-2.0]},
2531                "sizing":{"type":"fixed_qty","qty":1}}"#,
2532        )
2533        .unwrap();
2534        let candles: Vec<Candle> = (0i64..5)
2535            .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2536            .collect();
2537        // A bid-heavy book → positive top-of-book imbalance → entry.
2538        let book = OrderBook {
2539            bids: vec![Level {
2540                price: 100.0,
2541                size: 9.0,
2542            }],
2543            asks: vec![Level {
2544                price: 101.0,
2545                size: 1.0,
2546            }],
2547        };
2548        let books = vec![book; 5];
2549
2550        let with_feed = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
2551        assert!(with_feed.metrics.num_trades >= 1);
2552
2553        let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2554        assert_eq!(without_feed.metrics.num_trades, 0);
2555    }
2556
2557    #[test]
2558    fn trade_indicator_replays_bar_trades() {
2559        use crate::data::{TradePrint, TradeSide};
2560        let spec = StrategySpec::parse(
2561            r#"{"symbol":"x","timeframe":"1h",
2562                "indicators":{"cvd":{"type":"CumulativeVolumeDelta","params":[]}},
2563                "entry":{"gt":["cvd",0.0]},
2564                "exit":{"lt":["cvd",-1.0]},
2565                "sizing":{"type":"fixed_qty","qty":1}}"#,
2566        )
2567        .unwrap();
2568        let candles: Vec<Candle> = (0i64..5)
2569            .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2570            .collect();
2571        let buy = TradePrint {
2572            price: 100.0,
2573            size: 5.0,
2574            side: TradeSide::Buy,
2575            timestamp: 0,
2576        };
2577        // Two buy trades per bar → cumulative volume delta grows positive.
2578        let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![buy, buy]).collect();
2579
2580        let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
2581        assert!(with_feed.metrics.num_trades >= 1);
2582
2583        let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2584        assert_eq!(without_feed.metrics.num_trades, 0);
2585    }
2586
2587    #[test]
2588    fn funding_charges_an_open_long() {
2589        let with_funding = StrategySpec::parse(
2590            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2591                "entry":{"gt":[{"price":"close"},0]},
2592                "exit":{"in_position":false},
2593                "sizing":{"type":"fixed_qty","qty":1},
2594                "costs":{"funding":true}}"#,
2595        )
2596        .unwrap();
2597        let candles = [
2598            bar(0, 100.0, 100.0, 100.0, 100.0),
2599            bar(1, 100.0, 100.0, 100.0, 100.0),
2600            bar(2, 100.0, 100.0, 100.0, 100.0),
2601        ];
2602        let derivs = vec![sample_tick(0.01); 3]; // funding 1% of mark 100 = 1.0/bar
2603
2604        let funded = run_with_deriv(&with_funding, &candles, &derivs, 10_000.0).unwrap();
2605        assert!(funded.fees_paid > 0.0); // a long paid funding
2606
2607        let no_funding = StrategySpec::parse(
2608            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2609                "entry":{"gt":[{"price":"close"},0]},
2610                "exit":{"in_position":false},
2611                "sizing":{"type":"fixed_qty","qty":1}}"#,
2612        )
2613        .unwrap();
2614        let unfunded = run_with_deriv(&no_funding, &candles, &derivs, 10_000.0).unwrap();
2615        assert!(funded.equity.last().unwrap().equity < unfunded.equity.last().unwrap().equity);
2616    }
2617
2618    #[test]
2619    fn leverage_liquidation_closes_at_bankruptcy() {
2620        // 5x long: capital 1000, notional 5000 → qty 50, cash -4000, bankruptcy
2621        // price -(-4000)/50 = 80.
2622        let spec = StrategySpec::parse(
2623            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2624                "entry":{"gt":[{"price":"close"},0]},
2625                "exit":{"in_position":false},
2626                "sizing":{"type":"fixed_cash","cash":5000},
2627                "risk":{"max_leverage":5,"liquidation":true}}"#,
2628        )
2629        .unwrap();
2630        let candles = [
2631            bar(0, 100.0, 100.0, 100.0, 100.0), // signal
2632            bar(1, 100.0, 100.0, 95.0, 98.0),   // enter @ open 100; low 95 > 80: safe
2633            bar(2, 90.0, 90.0, 70.0, 75.0),     // low 70 <= 80: liquidate @ 80
2634        ];
2635        let r = run_with_capital(&spec, &candles, 1000.0).unwrap();
2636        assert_eq!(r.trades.len(), 1);
2637        assert_eq!(r.trades[0].reason, "liquidation");
2638        assert!((r.trades[0].exit_price - 80.0).abs() < 1e-9);
2639        assert!(r.equity.last().unwrap().equity.abs() < 1e-6); // account wiped out
2640    }
2641
2642    #[test]
2643    fn limit_entry_pays_maker_fee() {
2644        let candles = [
2645            bar(0, 100.0, 100.0, 100.0, 100.0),
2646            bar(1, 100.0, 100.0, 100.0, 100.0),
2647            bar(2, 100.0, 100.0, 100.0, 100.0),
2648        ];
2649        // A market entry pays the taker fee; a resting limit entry pays maker.
2650        let market = StrategySpec::parse(
2651            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2652                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2653                "sizing":{"type":"fixed_qty","qty":1},
2654                "costs":{"maker_bps":0,"taker_bps":200}}"#,
2655        )
2656        .unwrap();
2657        let limit = StrategySpec::parse(
2658            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2659                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2660                "sizing":{"type":"fixed_qty","qty":1},
2661                "costs":{"maker_bps":0,"taker_bps":200},
2662                "execution":{"order_type":"limit","limit_offset_pct":0.0}}"#,
2663        )
2664        .unwrap();
2665        let market_fees = run_with_capital(&market, &candles, 10_000.0)
2666            .unwrap()
2667            .fees_paid;
2668        let limit_fees = run_with_capital(&limit, &candles, 10_000.0)
2669            .unwrap()
2670            .fees_paid;
2671        assert!(limit_fees < market_fees); // maker (0) saved versus taker on entry
2672    }
2673
2674    #[test]
2675    fn volume_impact_slippage_worsens_the_fill() {
2676        let spec = StrategySpec::parse(
2677            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2678                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2679                "sizing":{"type":"fixed_qty","qty":10},
2680                "costs":{"slippage":{"type":"volume_impact","coef":0.5}}}"#,
2681        )
2682        .unwrap();
2683        let vbar = |t, vol| Candle {
2684            time: t,
2685            open: 100.0,
2686            high: 100.0,
2687            low: 100.0,
2688            close: 100.0,
2689            volume: vol,
2690        };
2691        let candles = [vbar(0, 1000.0), vbar(1, 1000.0), vbar(2, 1000.0)];
2692        let r = run_with_capital(&spec, &candles, 1_000_000.0).unwrap();
2693        // slip = coef * qty / volume = 0.5 * 10 / 1000 = 0.005 -> fill 100 * 1.005
2694        assert!((r.trades[0].entry_price - 100.5).abs() < 1e-9);
2695    }
2696
2697    #[test]
2698    fn spread_slippage_uses_the_order_book() {
2699        use crate::data::Level;
2700        let spec = StrategySpec::parse(
2701            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2702                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":false},
2703                "sizing":{"type":"fixed_qty","qty":1},
2704                "costs":{"slippage":{"type":"spread"}}}"#,
2705        )
2706        .unwrap();
2707        let candles = [
2708            bar(0, 100.0, 100.0, 100.0, 100.0),
2709            bar(1, 100.0, 100.0, 100.0, 100.0),
2710            bar(2, 100.0, 100.0, 100.0, 100.0),
2711        ];
2712        let book = OrderBook {
2713            bids: vec![Level {
2714                price: 99.0,
2715                size: 1.0,
2716            }],
2717            asks: vec![Level {
2718                price: 101.0,
2719                size: 1.0,
2720            }],
2721        };
2722        let books = vec![book; 3];
2723        let r = run_with_orderbook(&spec, &candles, &books, 10_000.0).unwrap();
2724        // half-spread / mid = 1 / 100 = 0.01 -> long entry fill 100 * 1.01 = 101
2725        assert!((r.trades[0].entry_price - 101.0).abs() < 1e-9);
2726    }
2727
2728    #[test]
2729    fn trade_quote_indicator_uses_trades_and_mid() {
2730        use crate::data::{TradePrint, TradeSide};
2731        let spec = StrategySpec::parse(
2732            r#"{"symbol":"x","timeframe":"1h",
2733                "indicators":{"es":{"type":"EffectiveSpread","params":[]}},
2734                "entry":{"gt":["es",0.0]},
2735                "exit":{"lt":["es",-1.0]},
2736                "sizing":{"type":"fixed_qty","qty":1}}"#,
2737        )
2738        .unwrap();
2739        let candles: Vec<Candle> = (0i64..5)
2740            .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2741            .collect();
2742        // Trades print away from the mid (close 100) -> positive effective spread.
2743        let trade = TradePrint {
2744            price: 102.0,
2745            size: 1.0,
2746            side: TradeSide::Buy,
2747            timestamp: 0,
2748        };
2749        let trades: Vec<Vec<TradePrint>> = (0..5).map(|_| vec![trade]).collect();
2750
2751        let with_feed = run_with_trades(&spec, &candles, &trades, 10_000.0).unwrap();
2752        assert!(with_feed.metrics.num_trades >= 1);
2753
2754        let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2755        assert_eq!(without_feed.metrics.num_trades, 0);
2756    }
2757
2758    #[test]
2759    fn cross_section_breadth_indicator_uses_feed() {
2760        use crate::data::{CrossSection, CrossSectionMember};
2761        let spec = StrategySpec::parse(
2762            r#"{"symbol":"x","timeframe":"1h",
2763                "indicators":{"ad":{"type":"AdvanceDecline","params":[]}},
2764                "entry":{"gt":["ad",0.0]},
2765                "exit":{"lt":["ad",-100.0]},
2766                "sizing":{"type":"fixed_qty","qty":1}}"#,
2767        )
2768        .unwrap();
2769        let candles: Vec<Candle> = (0i64..4)
2770            .map(|t| bar(t, 100.0, 100.0, 100.0, 100.0))
2771            .collect();
2772        let advancer = CrossSectionMember {
2773            change: 1.0,
2774            volume: 100.0,
2775            new_high: false,
2776            new_low: false,
2777        };
2778        let decliner = CrossSectionMember {
2779            change: -1.0,
2780            volume: 100.0,
2781            new_high: false,
2782            new_low: false,
2783        };
2784        // Three advancing vs one declining -> positive advance-decline.
2785        let section = CrossSection {
2786            members: vec![advancer, advancer, advancer, decliner],
2787            timestamp: 0,
2788        };
2789        let sections = vec![section; 4];
2790
2791        let with_feed = run_with_cross_section(&spec, &candles, &sections, 10_000.0).unwrap();
2792        assert!(with_feed.metrics.num_trades >= 1);
2793
2794        let without_feed = run_with_capital(&spec, &candles, 10_000.0).unwrap();
2795        assert_eq!(without_feed.metrics.num_trades, 0);
2796    }
2797
2798    #[test]
2799    fn run_with_deriv_rejects_length_mismatch() {
2800        let spec = StrategySpec::parse(
2801            r#"{"symbol":"x","timeframe":"1h","indicators":{},
2802                "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2803                "sizing":{"type":"fixed_qty","qty":1}}"#,
2804        )
2805        .unwrap();
2806        let candles = [bar(0, 1.0, 1.0, 1.0, 1.0), bar(1, 1.0, 1.0, 1.0, 1.0)];
2807        let derivs = [sample_tick(0.0)];
2808        assert!(run_with_deriv(&spec, &candles, &derivs, 10_000.0).is_err());
2809    }
2810
2811    #[test]
2812    fn new_owned_matches_the_borrowing_constructor() {
2813        let json = r#"{"symbol":"x","timeframe":"1h","indicators":{},
2814            "entry":{"gt":[{"price":"close"},0]},"exit":{"in_position":true},
2815            "sizing":{"type":"fixed_qty","qty":1}}"#;
2816        let spec = StrategySpec::parse(json).unwrap();
2817        let candles = [
2818            bar(0, 1.0, 1.0, 1.0, 1.0),
2819            bar(1, 1.0, 2.0, 1.0, 2.0),
2820            bar(2, 2.0, 3.0, 2.0, 3.0),
2821        ];
2822
2823        // Borrowing constructor.
2824        let mut borrowed = StreamingBacktest::new(&spec, 10_000.0).unwrap();
2825        for candle in &candles {
2826            borrowed.step(candle).unwrap();
2827        }
2828        let borrowed_report = borrowed.finish();
2829
2830        // Owned constructor: move the spec in; the handle carries no borrow.
2831        let mut owned = StreamingBacktest::new_owned(spec, 10_000.0).unwrap();
2832        for candle in &candles {
2833            owned.step(candle).unwrap();
2834        }
2835        let owned_report = owned.finish();
2836
2837        // The two paths must produce byte-identical reports.
2838        assert_eq!(
2839            serde_json::to_string(&owned_report).unwrap(),
2840            serde_json::to_string(&borrowed_report).unwrap()
2841        );
2842    }
2843}