Skip to main content

egui_charts/strategies/
backtest.rs

1//! The strategy trait, the context it trades through and the engine that runs it over bars.
2
3use chrono::{DateTime, TimeDelta, Utc};
4
5#[path = "live.rs"]
6mod live;
7pub use live::StrategyFill;
8
9use crate::model::rounding_tolerance;
10use crate::model::{
11    Bar, OrderSide, OrderType, StrategyExit, StrategyReport, StrategyTrade, Timeframe,
12};
13
14/// The default entry size, converted to units at the execution price.
15#[derive(Clone, Copy, Debug, PartialEq)]
16#[non_exhaustive]
17pub enum OrderSize {
18    /// A fixed number of units.
19    Fixed(f64),
20    /// A fraction of current marked equity, such as 0.1 for 10%.
21    EquityFraction(f64),
22    /// An amount in the account currency.
23    Cash(f64),
24}
25
26/// Commission charged once on the entire quantity of a fill, including a reversal.
27#[derive(Clone, Copy, Debug, PartialEq)]
28#[non_exhaustive]
29pub enum Commission {
30    /// A fraction of absolute fill value, such as 0.001 for 0.1%.
31    Fraction(f64),
32    /// An amount per unit filled.
33    PerUnit(f64),
34    /// An amount per filled order.
35    PerOrder(f64),
36}
37
38/// Adverse execution adjustment for market and stop fills. Limits never slip.
39#[derive(Clone, Copy, Debug, PartialEq)]
40#[non_exhaustive]
41pub enum Slippage {
42    /// A number of ticks, each of `tick_size` price units.
43    Ticks {
44        /// Number of ticks.
45        ticks: u32,
46        /// Price units per tick.
47        tick_size: f64,
48    },
49    /// A fraction of absolute price.
50    Fraction(f64),
51}
52
53/// Account and execution assumptions of a [`Backtest`].
54///
55/// Leverage below one or non-finite counts as one. Negative or non-finite commission amounts
56/// count as zero, as in the paper account; slippage amounts follow the same rule. Invalid entry
57/// sizes are rejected.
58#[derive(Clone, Debug, PartialEq)]
59#[non_exhaustive]
60pub struct BacktestSettings {
61    /// Starting account value in `currency`. Must be finite.
62    pub initial_capital: f64,
63    /// Currency of capital, equity and fees. Display only; no conversion is performed.
64    pub currency: Option<String>,
65    /// Annual risk-free fraction passed to the report's performance metrics.
66    pub risk_free_rate: f64,
67    /// Size used by entries that do not supply their own quantity.
68    pub order_size: OrderSize,
69    /// Maximum simultaneously open entries in one direction. Zero disables entries.
70    pub pyramiding: usize,
71    /// Fee paid on each fill and allocated to trades by quantity.
72    pub commission: Commission,
73    /// Adverse market and stop execution adjustment.
74    pub slippage: Slippage,
75    /// Margin is absolute position market value divided by this value.
76    pub leverage: f64,
77    /// Fill market orders at the close of the bar they are placed on.
78    pub fill_on_close: bool,
79    /// Decide again after every fill, at its execution price, without exposing future bars.
80    pub recalculate_after_fills: bool,
81    /// Decide on changes of the forming bar in [`Backtest::update`], retaining intrabar fills.
82    /// Historical [`Backtest::run`] still decides only on closed bars.
83    pub recalculate_on_every_update: bool,
84    /// First eligible chart-bar open. Earlier bars warm studies and strategy state without trading.
85    pub start_time: Option<DateTime<Utc>>,
86}
87
88impl Default for BacktestSettings {
89    fn default() -> Self {
90        Self {
91            initial_capital: 10_000.0,
92            currency: None,
93            risk_free_rate: 0.0,
94            order_size: OrderSize::Fixed(1.0),
95            pyramiding: 1,
96            commission: Commission::Fraction(0.0),
97            slippage: Slippage::Fraction(0.0),
98            leverage: 1.0,
99            fill_on_close: false,
100            recalculate_after_fills: false,
101            recalculate_on_every_update: false,
102            start_time: None,
103        }
104    }
105}
106
107fn nonnegative(value: f64) -> f64 {
108    if value.is_finite() {
109        value.max(0.0)
110    } else {
111        0.0
112    }
113}
114
115impl BacktestSettings {
116    fn fee(&self, quantity: f64, price: f64) -> f64 {
117        match self.commission {
118            Commission::Fraction(rate) => quantity * price.abs() * nonnegative(rate),
119            Commission::PerUnit(amount) => quantity * nonnegative(amount),
120            Commission::PerOrder(amount) => nonnegative(amount),
121        }
122    }
123
124    fn slipped(&self, price: f64, side: OrderSide) -> f64 {
125        let amount = match self.slippage {
126            Slippage::Ticks { ticks, tick_size } => f64::from(ticks) * nonnegative(tick_size),
127            Slippage::Fraction(rate) => price.abs() * nonnegative(rate),
128        };
129        price + sign(side) * amount
130    }
131
132    fn leverage(&self) -> f64 {
133        if self.leverage.is_finite() {
134            self.leverage.max(1.0)
135        } else {
136            1.0
137        }
138    }
139}
140
141/// A trading strategy that decides on closed bars and, when enabled, live ticks.
142///
143/// # Example
144///
145/// ```
146/// use egui_charts::model::{Bar, OrderSide, OrderType, Timeframe};
147/// use egui_charts::strategies::{Backtest, Strategy, StrategyContext};
148///
149/// /// Long while the close is above the previous close, flat otherwise.
150/// struct Momentum;
151///
152/// impl Strategy for Momentum {
153///     fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
154///         let [.., previous, bar] = context.bars() else {
155///             return;
156///         };
157///         if bar.close > previous.close && context.position() == 0.0 {
158///             context.entry("Long", OrderSide::Buy, 10.0, OrderType::Market);
159///         } else if bar.close < previous.close && context.position() > 0.0 {
160///             context.exit("Close", None, None, None);
161///         }
162///     }
163/// }
164///
165/// let bar = |minute: i64, price: f64| {
166///     let time = chrono::DateTime::UNIX_EPOCH + chrono::TimeDelta::minutes(minute);
167///     Bar::new(time, price, price, price, price, 0.0)
168/// };
169/// let bars = vec![bar(0, 100.0), bar(1, 101.0), bar(2, 103.0), bar(3, 102.0), bar(4, 101.0)];
170/// let report = Backtest::new("Momentum", "AAPL", Timeframe::Min1, 10_000.0).run(&mut Momentum, &bars, &[]);
171/// // Bought at the next open after the rise to 101, sold at the next open after the fall to 102.
172/// assert_eq!(report.trades.len(), 1);
173/// assert_eq!(report.trades[0].entry_price, 103.0);
174/// assert_eq!(report.trades[0].exit.as_ref().map(|exit| exit.profit), Some(-20.0));
175/// assert_eq!(report.equity.len(), bars.len() + 1);
176/// ```
177pub trait Strategy {
178    /// Named strategy inputs, copied to the report's properties before the run.
179    fn inputs(&self) -> Vec<(String, String)> {
180        self.study_inputs()
181            .into_iter()
182            .map(|input| {
183                use crate::studies::{InputKind, InputValue};
184                let value = match input.value {
185                    InputValue::Length(value) => value.to_string(),
186                    InputValue::Integer(value) => value.to_string(),
187                    InputValue::Number(value) => value.to_string(),
188                    InputValue::Toggle(value) => value.to_string(),
189                    InputValue::Choice(index) => match input.kind {
190                        InputKind::Choice(choices) => {
191                            choices.get(index).cloned().unwrap_or_default()
192                        }
193                        _ => index.to_string(),
194                    },
195                    InputValue::Text(value) | InputValue::Symbol(value) => value,
196                    value => format!("{value:?}"),
197                };
198                (input.label, value)
199            })
200            .collect()
201    }
202
203    /// Editable calculation inputs, including defaults and accepted bounds.
204    fn study_inputs(&self) -> Vec<crate::studies::StudyInput> {
205        Vec::new()
206    }
207
208    /// Changes a calculation input. Call `prepare` again before using cached calculations.
209    /// Implementations reject unknown ids, wrong types and values outside their schema.
210    fn set_input(
211        &mut self,
212        id: &str,
213        value: crate::studies::InputValue,
214    ) -> Result<(), &'static str> {
215        let _ = (id, value);
216        Err("strategy has no editable inputs")
217    }
218
219    /// Computes what the strategy needs from the whole run once, before the first bar, such as
220    /// its studies. A value at a bar must depend only on that bar and earlier ones, as a study's
221    /// does, or the run looks ahead. Reset any decision state here so a corrected history can
222    /// start a fresh run. Does nothing by default.
223    fn prepare(&mut self, bars: &[Bar]) {
224        let _ = bars;
225    }
226
227    /// Refreshes calculations for appended bars or a changed forming bar, preserving decision
228    /// state from `on_bar`. The default calls `prepare`; override it if `prepare` resets that
229    /// state. Like `prepare`, calculations must use only the prefix through each bar.
230    fn prepare_update(&mut self, bars: &[Bar]) {
231        self.prepare(bars);
232    }
233
234    /// Decides on the bar that just closed, the last of [`StrategyContext::bars`], by placing and
235    /// cancelling orders. With fill recalculation enabled this also runs immediately after a fill;
236    /// use the context's price and fill flag rather than reading an unfinished bar. Live updates
237    /// also call this on forming-bar changes when enabled; `is_forming_bar` identifies them. Calls before
238    /// the start time warm the strategy's state, but cannot place orders.
239    fn on_bar(&mut self, context: &mut StrategyContext<'_>);
240}
241
242/// What a [`Strategy`] sees at a decision: the bars up to it and the open position. It places
243/// and cancels working orders by id.
244///
245/// An order placed with the id of a working order replaces it, keeping its original place
246/// among orders reached at the same point. Working orders stay until they fill or are cancelled. A
247/// quantity must be positive and finite and a price finite; an order with any other is ignored.
248pub struct StrategyContext<'a> {
249    bars: &'a [Bar],
250    position: f64,
251    orders: &'a mut Vec<Working>,
252    price: f64,
253    equity: f64,
254    after_fill: bool,
255    forming: bool,
256    trading: bool,
257    tick: u64,
258}
259
260impl StrategyContext<'_> {
261    /// Chart bars through this decision, oldest first. Live tick decisions include the forming
262    /// bar; intrabar fill callbacks exclude the unfinished bar. See [`Self::is_forming_bar`].
263    pub fn bars(&self) -> &[Bar] {
264        self.bars
265    }
266
267    /// The open position: positive when long, negative when short, 0 when flat.
268    pub fn position(&self) -> f64 {
269        self.position
270    }
271
272    /// The latest close on a bar decision, or the execution price immediately after a fill.
273    pub fn price(&self) -> f64 {
274        self.price
275    }
276
277    /// Account equity marked at [`Self::price`], after any fill commission.
278    pub fn equity(&self) -> f64 {
279        self.equity
280    }
281
282    /// Whether this is a recalculation immediately after a fill rather than a closed bar.
283    pub fn is_after_fill(&self) -> bool {
284        self.after_fill
285    }
286
287    /// Whether the current decision belongs to a still-forming live bar. Fill callbacks
288    /// continue to exclude that bar from `bars`, even while this flag is true.
289    pub fn is_forming_bar(&self) -> bool {
290        self.forming
291    }
292
293    /// Places an entry of `quantity` on `side`. Against an open position on the other side it
294    /// closes that position first, then opens `quantity` on `side`; on the same side it adds.
295    /// Pass `None` to use the settings' order size, calculated when the order fills.
296    pub fn entry(
297        &mut self,
298        id: impl Into<String>,
299        side: OrderSide,
300        quantity: impl Into<Option<f64>>,
301        order_type: OrderType,
302    ) {
303        let finite = match order_type {
304            OrderType::Market => true,
305            OrderType::Limit(price) | OrderType::Stop(price) => price.is_finite(),
306            OrderType::StopLimit { stop, limit } => stop.is_finite() && limit.is_finite(),
307        };
308        if finite {
309            self.place(id.into(), quantity.into(), Kind::Entry(side, order_type));
310        }
311    }
312
313    /// Places an exit that reduces the open position by `quantity`, or closes it with `None`. It
314    /// fills at the `take_profit` limit or the `stop_loss` stop, whichever the price reaches
315    /// first, or at market without either. An exit only reduces the position open when it is
316    /// placed or, placed while flat, the position the next entry opens; once that position is
317    /// closed or reversed, the exit is cancelled.
318    pub fn exit(
319        &mut self,
320        id: impl Into<String>,
321        quantity: Option<f64>,
322        take_profit: Option<f64>,
323        stop_loss: Option<f64>,
324    ) {
325        if take_profit.into_iter().chain(stop_loss).all(f64::is_finite) {
326            let side = exit_side(self.position);
327            self.place(
328                id.into(),
329                quantity,
330                Kind::Exit(take_profit, stop_loss, side),
331            );
332        }
333    }
334
335    /// Cancels the working order with this id, if any.
336    pub fn cancel(&mut self, id: &str) {
337        self.orders.retain(|order| order.id != id);
338    }
339
340    /// Brackets entries on `side` at positive price distances from their average fill price.
341    /// The bracket can precede its entry and protects it on its execution bar, including gaps
342    /// and slippage. Adding an entry moves the bracket with the position's average price.
343    /// It closes the whole position, then protects any queued entries on that side. Without
344    /// a queued entry it is cancelled when the position closes or reverses.
345    pub fn exit_relative(
346        &mut self,
347        id: impl Into<String>,
348        side: OrderSide,
349        take_profit: f64,
350        stop_loss: f64,
351    ) {
352        if [take_profit, stop_loss]
353            .iter()
354            .all(|value| value.is_finite() && *value > 0.0)
355        {
356            self.place(
357                id.into(),
358                None,
359                Kind::RelativeExit(take_profit, stop_loss, side),
360            );
361        }
362    }
363
364    fn place(&mut self, id: String, quantity: Option<f64>, kind: Kind) {
365        if !self.trading {
366            return;
367        }
368        if quantity.is_some_and(|quantity| !quantity.is_finite() || quantity <= 0.0) {
369            return;
370        }
371        let order = Working {
372            id,
373            quantity,
374            kind,
375            deferred: (self.after_fill || self.forming).then_some(self.tick),
376        };
377        match self
378            .orders
379            .iter_mut()
380            .find(|working| working.id == order.id)
381        {
382            Some(working) => *working = order,
383            None => self.orders.push(order),
384        }
385    }
386}
387
388/// A working order of a backtest.
389#[derive(Clone, Debug, PartialEq)]
390struct Working {
391    id: String,
392    /// `None` for an exit of the whole position or an entry using the default size.
393    quantity: Option<f64>,
394    kind: Kind,
395    /// Orders created after a fill or on a forming-bar decision wait for a subsequent price point.
396    deferred: Option<u64>,
397}
398
399#[derive(Clone, Copy, Debug, PartialEq)]
400enum Kind {
401    Entry(OrderSide, OrderType),
402    /// Take profit, stop loss and the exit's side, `None` until a position opens for it to reduce.
403    Exit(Option<f64>, Option<f64>, Option<OrderSide>),
404    /// Distances from the position's average fill price and the entry side to protect.
405    RelativeExit(f64, f64, OrderSide),
406}
407
408/// An entry's quantity that is still open.
409#[derive(Clone, Debug, PartialEq)]
410struct Lot {
411    entry: String,
412    side: OrderSide,
413    quantity: f64,
414    entry_quantity: f64,
415    time: DateTime<Utc>,
416    price: f64,
417    fees: f64,
418}
419
420/// Runs a [`Strategy`] over bars and reports the result as a [`StrategyReport`].
421///
422/// The strategy decides on each bar once it closes. Its orders fill no earlier than the next
423/// bar, along that bar's price path: from the open to the extreme nearer the open (the high when
424/// both are as near within a few ulps), then to the other extreme, then to the close. A market
425/// order fills at the open. A limit, stop or stop-limit order fills where the path reaches it, at
426/// its limit or stop price, or at the open when the bar opened beyond it; a triggered stop-limit
427/// order works on as a limit order from its stop. Orders the path reaches at the same point fill
428/// in the order they were first placed. An exit with a take profit and a stop loss fills whichever
429/// the path reaches first. With `settings.fill_on_close`, market orders fill at the
430/// close of the bar they were placed on instead.
431///
432/// Each entry is a trade, closed by exits and opposite entries first in first out; each partial
433/// exit is its own trade with its share of the entry. A trade's label names its entry and exit
434/// orders, such as "Long / Take profit", or the entry alone while open. Profit is the price
435/// difference times the quantity, less its allocated fees. Without a start time, the equity curve
436/// holds the initial capital one interval before the first bar, then the account value at each
437/// bar's close, stamped with the bar's time. With a start time it begins there and subsequent
438/// marks use chart-bar closing times. Fills use the times of their chart or finer bars.
439/// Close fills on trade-based bars use the last source time, which stays fixed as bars arrive.
440///
441/// Bars are oldest first, in strictly increasing time order. A bar without finite prices, or
442/// whose high and low do not contain its open and close, fills no orders and has no equity value.
443/// Settings apply sizing, pyramiding, costs and margin at each execution; a margin deficit closes
444/// the position along the path. Optional finer bars determine fills within each chart bar.
445#[derive(Clone, Debug, PartialEq)]
446#[non_exhaustive]
447pub struct Backtest {
448    /// What the report calls the run.
449    pub name: String,
450    /// The instrument the bars are of.
451    pub symbol: String,
452    /// The bar interval.
453    pub timeframe: Timeframe,
454    /// Account and execution assumptions, also recorded in the report's properties.
455    pub settings: BacktestSettings,
456    live: Option<Box<live::LiveState>>,
457}
458
459impl Backtest {
460    /// A backtest that fills market orders at the next bar's open.
461    pub fn new(
462        name: impl Into<String>,
463        symbol: impl Into<String>,
464        timeframe: Timeframe,
465        initial_capital: f64,
466    ) -> Self {
467        Self {
468            name: name.into(),
469            symbol: symbol.into(),
470            timeframe,
471            live: None,
472            settings: BacktestSettings {
473                initial_capital,
474                ..Default::default()
475            },
476        }
477    }
478
479    /// Runs `strategy` over `bars` and reports its trades, equity and assumptions.
480    ///
481    /// `lower` supplies finer bars of the same symbol. Valid bars are walked in time order inside
482    /// each chart bar; a chart bar without any falls back to its own path. Decisions and equity
483    /// remain on the chart interval. Pass `&[]` to use chart bars alone.
484    ///
485    /// With a start time, `prepare` still receives all bars, but chart bars opening before it
486    /// cannot trade. Equity starts at the requested time and subsequent points use chart-bar
487    /// closes, so the starting value and first close have distinct timestamps. A final execution
488    /// bar without a known end labels its equity mark one nanosecond after its last source time.
489    ///
490    /// # Panics
491    ///
492    /// Panics if the starting capital is not finite.
493    pub fn run(&self, strategy: &mut dyn Strategy, bars: &[Bar], lower: &[Bar]) -> StrategyReport {
494        finish_now(self.run_inner(strategy, bars, lower, false))
495    }
496
497    /// Runs the same fills as [`Self::run`], yielding between batches of 256 chart or finer bars.
498    ///
499    /// Poll from an executor or once per frame. Dropping the future cancels the run. Strategy
500    /// preparation and individual callbacks remain synchronous, so hosts should keep them bounded.
501    ///
502    /// # Panics
503    ///
504    /// Panics if the starting capital is not finite.
505    pub async fn run_async(
506        &self,
507        strategy: &mut dyn Strategy,
508        bars: &[Bar],
509        lower: &[Bar],
510    ) -> StrategyReport {
511        self.run_inner(strategy, bars, lower, true).await
512    }
513
514    async fn run_inner(
515        &self,
516        strategy: &mut dyn Strategy,
517        bars: &[Bar],
518        lower: &[Bar],
519        cooperative: bool,
520    ) -> StrategyReport {
521        let settings = &self.settings;
522        let mut report = self.new_report(strategy, lower.len());
523        let mut lower: Vec<_> = lower.iter().filter(|bar| valid_bar(bar)).collect();
524        lower.sort_by_key(|bar| bar.time);
525        lower.dedup_by_key(|bar| bar.time);
526        strategy.prepare(bars);
527        let mut run = Run {
528            orders: Vec::new(),
529            lots: Vec::new(),
530            trades: Vec::new(),
531            realized: 0.0,
532            rejected: 0,
533            settings,
534            strategy,
535            history: &[],
536            trading: false,
537            tick: 0,
538            fills: None,
539            forming: false,
540        };
541        run.start_equity(self.timeframe, bars, &mut report);
542        let mut processed = cooperative.then_some(0);
543        for index in 0..bars.len() {
544            run.closed_bar_inner(bars, index, &lower, &mut report, true, &mut processed)
545                .await;
546        }
547        run.report_trades(&mut report);
548        report
549    }
550
551    fn new_report(&self, strategy: &dyn Strategy, lower_count: usize) -> StrategyReport {
552        let settings = &self.settings;
553        assert!(
554            settings.initial_capital.is_finite(),
555            "starting capital must be finite"
556        );
557        let mut report = StrategyReport::new(
558            &self.name,
559            &self.symbol,
560            self.timeframe,
561            settings.initial_capital,
562        );
563        report.currency = settings.currency.clone();
564        report.risk_free_rate = settings.risk_free_rate;
565        let size = match settings.order_size {
566            OrderSize::Fixed(value) => format!("Fixed units: {value}"),
567            OrderSize::EquityFraction(value) => format!("Equity fraction: {value}"),
568            OrderSize::Cash(value) => format!("Cash: {value}"),
569        };
570        let commission = match settings.commission {
571            Commission::Fraction(value) => format!("Fraction: {}", nonnegative(value)),
572            Commission::PerUnit(value) => format!("Per unit: {}", nonnegative(value)),
573            Commission::PerOrder(value) => format!("Per order: {}", nonnegative(value)),
574        };
575        let slippage = match settings.slippage {
576            Slippage::Fraction(value) => format!("Fraction: {}", nonnegative(value)),
577            Slippage::Ticks { ticks, tick_size } => {
578                format!("{ticks} ticks of {}", nonnegative(tick_size))
579            }
580        };
581        report.properties = vec![
582            ("Order size".into(), size),
583            ("Pyramiding".into(), settings.pyramiding.to_string()),
584            ("Commission".into(), commission),
585            ("Slippage".into(), slippage),
586            ("Leverage".into(), settings.leverage().to_string()),
587            ("Fill on close".into(), settings.fill_on_close.to_string()),
588            (
589                "Recalculate after fills".into(),
590                settings.recalculate_after_fills.to_string(),
591            ),
592            (
593                "Recalculate on every update".into(),
594                settings.recalculate_on_every_update.to_string(),
595            ),
596        ];
597        report.properties.push((
598            "Start time".into(),
599            settings
600                .start_time
601                .map_or_else(|| "All supplied history".into(), |time| time.to_rfc3339()),
602        ));
603        report
604            .properties
605            .push(("Lower-interval bars".into(), lower_count.to_string()));
606        report.properties.extend(
607            strategy
608                .inputs()
609                .into_iter()
610                .map(|(name, value)| (format!("Input: {name}"), value)),
611        );
612        report
613    }
614}
615
616fn finish_now<T>(future: impl std::future::Future<Output = T>) -> T {
617    let mut future = std::pin::pin!(future);
618    let mut context = std::task::Context::from_waker(std::task::Waker::noop());
619    match std::future::Future::poll(future.as_mut(), &mut context) {
620        std::task::Poll::Ready(output) => output,
621        std::task::Poll::Pending => unreachable!("synchronous execution never yields"),
622    }
623}
624
625async fn execution_step(processed: &mut Option<usize>) {
626    if let Some(processed) = processed {
627        if processed.is_multiple_of(256) {
628            yield_now().await;
629        }
630        *processed += 1;
631    }
632}
633
634async fn yield_now() {
635    let mut yielded = false;
636    std::future::poll_fn(|context| {
637        if yielded {
638            std::task::Poll::Ready(())
639        } else {
640            yielded = true;
641            context.waker().wake_by_ref();
642            std::task::Poll::Pending
643        }
644    })
645    .await;
646}
647
648/// Calendar months have calendar boundaries; execution bars end at the next chart open.
649fn bar_end(timeframe: Timeframe, bar: &Bar, next: Option<&Bar>) -> Option<DateTime<Utc>> {
650    match timeframe {
651        Timeframe::Month1 => bar.time.checked_add_months(chrono::Months::new(1)),
652        Timeframe::Months(count) => bar
653            .time
654            .checked_add_months(chrono::Months::new(count.get())),
655        _ if timeframe.is_trade_based() => next.map(|bar| bar.time),
656        _ => bar.time.checked_add_signed(timeframe.duration()),
657    }
658}
659
660pub(super) fn valid_bar(bar: &Bar) -> bool {
661    [bar.open, bar.high, bar.low, bar.close]
662        .iter()
663        .all(|price| price.is_finite())
664        && bar.low <= bar.open.min(bar.close)
665        && bar.high >= bar.open.max(bar.close)
666}
667
668/// The side of an exit from `position`, `None` while flat.
669fn exit_side(position: f64) -> Option<OrderSide> {
670    if position > 0.0 {
671        Some(OrderSide::Sell)
672    } else if position < 0.0 {
673        Some(OrderSide::Buy)
674    } else {
675        None
676    }
677}
678
679fn sign(side: OrderSide) -> f64 {
680    match side {
681        OrderSide::Buy => 1.0,
682        OrderSide::Sell => -1.0,
683    }
684}
685
686struct Run<'a> {
687    orders: Vec<Working>,
688    /// Open entries, oldest first, all on one side.
689    lots: Vec<Lot>,
690    trades: Vec<StrategyTrade>,
691    /// Realized price profits less every commission paid, including open entries' fees.
692    realized: f64,
693    rejected: usize,
694    settings: &'a BacktestSettings,
695    strategy: &'a mut dyn Strategy,
696    history: &'a [Bar],
697    trading: bool,
698    tick: u64,
699    fills: Option<&'a mut Vec<StrategyFill>>,
700    forming: bool,
701}
702
703impl<'a> Run<'a> {
704    fn start_equity(&self, timeframe: Timeframe, bars: &[Bar], report: &mut StrategyReport) {
705        if let Some(first) = bars.iter().find(|bar| {
706            self.settings
707                .start_time
708                .is_none_or(|start| bar.time >= start)
709        }) {
710            let interval = timeframe.duration().max(TimeDelta::milliseconds(1));
711            let before = first.time.checked_sub_signed(interval);
712            report.equity.push((
713                self.settings
714                    .start_time
715                    .unwrap_or(before.unwrap_or(DateTime::<Utc>::MIN_UTC)),
716                self.settings.initial_capital,
717            ));
718        }
719    }
720
721    fn closed_bar(
722        &mut self,
723        bars: &'a [Bar],
724        index: usize,
725        lower: &[&Bar],
726        report: &mut StrategyReport,
727        walk: bool,
728    ) {
729        finish_now(self.closed_bar_inner(bars, index, lower, report, walk, &mut None));
730    }
731
732    async fn closed_bar_inner(
733        &mut self,
734        bars: &'a [Bar],
735        index: usize,
736        lower: &[&Bar],
737        report: &mut StrategyReport,
738        walk: bool,
739        processed: &mut Option<usize>,
740    ) {
741        execution_step(processed).await;
742        self.forming = false;
743        let timeframe = report.timeframe;
744        let bar = &bars[index];
745        self.trading = self
746            .settings
747            .start_time
748            .is_none_or(|start| bar.time >= start);
749        if !self.trading {
750            self.history = &bars[..=index];
751            self.decide(bar.close, false);
752            return;
753        }
754        let end = bar_end(timeframe, bar, bars.get(index + 1));
755        self.history = &bars[..index];
756        let valid = valid_bar(bar);
757        let from = lower.partition_point(|lower| lower.time < bar.time);
758        let to = lower.partition_point(|lower| end.is_none_or(|end| lower.time < end));
759        let last_price_time = if from < to {
760            lower[to - 1].time
761        } else {
762            bar.time
763        };
764        if valid && walk {
765            if from < to {
766                for lower in &lower[from..to] {
767                    execution_step(processed).await;
768                    self.fill_bar(lower);
769                }
770            } else {
771                self.fill_bar(bar);
772            }
773        }
774        self.history = &bars[..=index];
775        self.decide(bar.close, false);
776        if valid && self.settings.fill_on_close {
777            // Execution bars have no fixed end; keep fill times stable as history grows.
778            let close_time = if timeframe.is_trade_based() {
779                last_price_time
780            } else if from < to || self.settings.start_time.is_some() {
781                end.unwrap_or(last_price_time)
782            } else {
783                bar.time
784            };
785            self.fill_markets(bar.close, close_time);
786        }
787        report.equity.push((
788            if self.settings.start_time.is_some() {
789                // A final execution bar has no known end; keep its mark after its last price.
790                end.unwrap_or_else(|| {
791                    last_price_time
792                        .checked_add_signed(TimeDelta::nanoseconds(1))
793                        .unwrap_or(last_price_time)
794                })
795            } else {
796                bar.time
797            },
798            if valid {
799                self.equity(bar.close)
800            } else {
801                f64::NAN
802            },
803        ));
804    }
805
806    fn report_trades(&self, report: &mut StrategyReport) {
807        report
808            .properties
809            .retain(|(name, _)| name != "Rejected orders");
810        report
811            .properties
812            .push(("Rejected orders".into(), self.rejected.to_string()));
813        report.trades = self.trades.clone();
814        report.trades.extend(self.lots.iter().map(|lot| {
815            let mut trade = StrategyTrade::new(lot.side, lot.quantity, lot.time, lot.price);
816            trade.label = Some(lot.entry.clone());
817            trade.fees = lot.fees;
818            trade
819        }));
820    }
821
822    fn position(&self) -> f64 {
823        self.lots.iter().fold(0.0, |position, lot| {
824            position + sign(lot.side) * lot.quantity
825        })
826    }
827
828    fn equity(&self, price: f64) -> f64 {
829        self.settings.initial_capital
830            + self.realized
831            + self
832                .lots
833                .iter()
834                .map(|lot| sign(lot.side) * (price - lot.price) * lot.quantity)
835                .sum::<f64>()
836    }
837
838    fn decide(&mut self, price: f64, after_fill: bool) {
839        let position = self.position();
840        let equity = self.equity(price);
841        self.strategy.on_bar(&mut StrategyContext {
842            bars: self.history,
843            position,
844            orders: &mut self.orders,
845            price,
846            equity,
847            after_fill,
848            forming: self.forming,
849            trading: self.trading,
850            tick: self.tick,
851        });
852    }
853
854    fn fill_bar(&mut self, bar: &Bar) {
855        self.tick += 1;
856        let up = bar.high - bar.open;
857        let down = bar.open - bar.low;
858        let near = if up - down
859            <= rounding_tolerance(bar.high.abs().max(bar.low.abs()), 4.0 * f64::EPSILON)
860        {
861            bar.high
862        } else {
863            bar.low
864        };
865        let far = if near == bar.high { bar.low } else { bar.high };
866        for (start, end) in [(bar.open, near), (near, far), (far, bar.close)] {
867            self.fill_along(start, end, bar.time);
868        }
869    }
870
871    /// Trigger price and whether the execution slips. Stop-limit triggers are not executions.
872    fn fill_price(&self, order: &Working, start: f64, end: f64) -> Option<(f64, bool)> {
873        let reached = match order.kind {
874            Kind::RelativeExit(profit, loss, side) => {
875                let quantity = self.position();
876                if quantity * sign(side) <= 0.0 {
877                    return None;
878                }
879                let average = self
880                    .lots
881                    .iter()
882                    .map(|lot| lot.price * (lot.quantity / quantity.abs()))
883                    .sum::<f64>();
884                let exit = Working {
885                    id: String::new(),
886                    quantity: None,
887                    kind: Kind::Exit(
888                        Some(average + sign(side) * profit),
889                        Some(average - sign(side) * loss),
890                        exit_side(quantity),
891                    ),
892                    deferred: order.deferred,
893                };
894                return self.fill_price(&exit, start, end);
895            }
896            Kind::Entry(side, order_type) => order_type.fill_price(side, start, end).map(|price| {
897                (
898                    price,
899                    matches!(order_type, OrderType::Market | OrderType::Stop(_)),
900                )
901            }),
902            Kind::Exit(take_profit, stop_loss, side) => {
903                let side = side?;
904                if take_profit.is_none() && stop_loss.is_none() {
905                    Some((start, true))
906                } else {
907                    // The stop first, so it wins a tie.
908                    [
909                        (stop_loss.map(OrderType::Stop), true),
910                        (take_profit.map(OrderType::Limit), false),
911                    ]
912                    .into_iter()
913                    .filter_map(|(order_type, slips)| {
914                        order_type?
915                            .fill_price(side, start, end)
916                            .map(|price| (price, slips))
917                    })
918                    .min_by(|(a, _), (b, _)| (a - start).abs().total_cmp(&(b - start).abs()))
919                }
920            }
921        }?;
922        if order.deferred == Some(self.tick) && reached.0 == start {
923            // A newly placed marketable order waits for the next price point, avoiding a
924            // strategy repeatedly entering and exiting on the same tick.
925            if start == end {
926                return None;
927            }
928            let next = Working {
929                id: String::new(),
930                quantity: order.quantity,
931                kind: order.kind,
932                deferred: None,
933            };
934            self.fill_price(&next, end, end)
935        } else {
936            Some(reached)
937        }
938    }
939
940    /// First point where equity would fall below market-value margin on this linear segment.
941    fn margin_call(&self, start: f64, end: f64) -> Option<f64> {
942        if self.lots.is_empty() {
943            return None;
944        }
945        let available = |price: f64| {
946            self.equity(price) - self.position().abs() * price.abs() / self.settings.leverage()
947        };
948        let mut from = start;
949        // Absolute market value changes slope at zero.
950        for to in [
951            if start.min(end) < 0.0 && start.max(end) > 0.0 {
952                0.0
953            } else {
954                end
955            },
956            end,
957        ] {
958            let a = available(from);
959            let b = available(to);
960            if a < 0.0 {
961                return Some(from);
962            }
963            if b < 0.0 {
964                return Some(from + (to - from) * (a / (a - b)));
965            }
966            from = to;
967        }
968        None
969    }
970
971    /// Fills orders and margin calls in price order along a bar segment.
972    fn fill_along(&mut self, mut start: f64, end: f64, time: DateTime<Utc>) {
973        let mut at_end = false;
974        loop {
975            let mut next: Option<(usize, f64, bool)> = None;
976            for (index, order) in self.orders.iter().enumerate() {
977                if let Some((price, slips)) = self.fill_price(order, start, end)
978                    && next.is_none_or(|(_, best, _)| (price - start).abs() < (best - start).abs())
979                {
980                    next = Some((index, price, slips));
981                }
982            }
983            // An order at the boundary can avert a deficit; an existing deficit wins a tie.
984            if let Some(price) = self.margin_call(start, end)
985                && next.is_none_or(|(_, best, _)| {
986                    (price - start).abs() < (best - start).abs()
987                        || (price == start
988                            && self.equity(start)
989                                < self.position().abs() * start.abs() / self.settings.leverage())
990                })
991            {
992                if price != start {
993                    self.tick += 1;
994                    at_end = price == end;
995                }
996                start = price;
997                self.fill(
998                    Working {
999                        id: "Margin call".into(),
1000                        quantity: None,
1001                        kind: Kind::Exit(None, None, exit_side(self.position())),
1002                        deferred: None,
1003                    },
1004                    price,
1005                    true,
1006                    time,
1007                );
1008                if !self.lots.is_empty() {
1009                    return;
1010                }
1011                continue;
1012            }
1013            let Some((index, price, slips)) = next else {
1014                if !at_end {
1015                    // Equal OHLC prices still denote successive points in the bar.
1016                    self.tick += 1;
1017                    start = end;
1018                    at_end = true;
1019                    continue;
1020                }
1021                return;
1022            };
1023            if price != start {
1024                self.tick += 1;
1025                at_end = price == end;
1026            }
1027            start = price;
1028            if let Kind::Entry(side, OrderType::StopLimit { limit, .. }) = self.orders[index].kind {
1029                self.orders[index].kind = Kind::Entry(side, OrderType::Limit(limit));
1030                self.orders[index].deferred = None;
1031                continue;
1032            }
1033            let order = self.orders.remove(index);
1034            self.fill(order, price, slips, time);
1035        }
1036    }
1037
1038    /// Fill only market orders on the closing tick, then check the account's margin.
1039    fn fill_markets(&mut self, price: f64, time: DateTime<Utc>) {
1040        let mut index = 0;
1041        while index < self.orders.len() {
1042            let order = &self.orders[index];
1043            let market = matches!(
1044                order.kind,
1045                Kind::Entry(_, OrderType::Market) | Kind::Exit(None, None, _)
1046            );
1047            if market && self.fill_price(order, price, price).is_some() {
1048                let order = self.orders.remove(index);
1049                self.fill(order, price, true, time);
1050                index = 0;
1051            } else {
1052                index += 1;
1053            }
1054        }
1055        if self.margin_call(price, price).is_some() {
1056            self.fill(
1057                Working {
1058                    id: "Margin call".into(),
1059                    quantity: None,
1060                    kind: Kind::Exit(None, None, exit_side(self.position())),
1061                    deferred: None,
1062                },
1063                price,
1064                true,
1065                time,
1066            );
1067        }
1068    }
1069
1070    fn fill(&mut self, order: Working, mark: f64, slips: bool, time: DateTime<Utc>) {
1071        let side = match order.kind {
1072            Kind::Entry(side, _) => side,
1073            Kind::RelativeExit(_, _, side) => {
1074                if side == OrderSide::Buy {
1075                    OrderSide::Sell
1076                } else {
1077                    OrderSide::Buy
1078                }
1079            }
1080            Kind::Exit(.., Some(side)) => side,
1081            Kind::Exit(.., None) => return,
1082        };
1083        let price = if slips {
1084            self.settings.slipped(mark, side)
1085        } else {
1086            mark
1087        };
1088        let held = self.position().abs();
1089        let order_id = self.fills.as_ref().map(|_| order.id.clone());
1090        let filled_quantity;
1091        match order.kind {
1092            Kind::Entry(..) => {
1093                let reversing = self.lots.first().is_some_and(|lot| lot.side != side);
1094                if self.settings.pyramiding == 0
1095                    || (!reversing && self.lots.len() >= self.settings.pyramiding)
1096                {
1097                    return;
1098                }
1099                let quantity = order
1100                    .quantity
1101                    .unwrap_or_else(|| match self.settings.order_size {
1102                        OrderSize::Fixed(value) => value,
1103                        OrderSize::EquityFraction(value) => self.equity(mark) * value / price.abs(),
1104                        OrderSize::Cash(value) => value / price.abs(),
1105                    });
1106                let filled = quantity + if reversing { held } else { 0.0 };
1107                let fee = self.settings.fee(filled, price);
1108                let after = quantity + if reversing { 0.0 } else { held };
1109                let equity = self.equity(mark) + sign(side) * (mark - price) * filled;
1110                let needed = after * mark.abs() / self.settings.leverage() + fee;
1111                if !quantity.is_finite()
1112                    || quantity <= 0.0
1113                    || !price.is_finite()
1114                    || !needed.is_finite()
1115                    || !equity.is_finite()
1116                    || needed > equity
1117                {
1118                    self.rejected += 1;
1119                    return;
1120                }
1121                filled_quantity = filled;
1122                self.realized -= fee;
1123                if reversing {
1124                    self.close(f64::INFINITY, &order.id, price, fee, filled, time);
1125                }
1126                self.lots.push(Lot {
1127                    entry: order.id,
1128                    side,
1129                    quantity,
1130                    entry_quantity: quantity,
1131                    time,
1132                    price,
1133                    fees: fee * (quantity / filled),
1134                });
1135            }
1136            Kind::Exit(..) | Kind::RelativeExit(..) => {
1137                let quantity = order.quantity.unwrap_or(held).min(held);
1138                if quantity <= 0.0 {
1139                    return;
1140                }
1141                let fee = self.settings.fee(quantity, price);
1142                if !price.is_finite() || !fee.is_finite() {
1143                    return;
1144                }
1145                filled_quantity = quantity;
1146                self.realized -= fee;
1147                self.close(
1148                    order.quantity.unwrap_or(f64::INFINITY),
1149                    &order.id,
1150                    price,
1151                    fee,
1152                    quantity,
1153                    time,
1154                );
1155                if let Kind::RelativeExit(_, _, side) = order.kind
1156                    && self.orders.iter().any(|working| {
1157                        matches!(working.kind, Kind::Entry(entry_side, _) if entry_side == side)
1158                    })
1159                {
1160                    self.orders.push(order);
1161                }
1162            }
1163        }
1164        let position = self.position();
1165        if let Some(fills) = &mut self.fills {
1166            fills.push(StrategyFill {
1167                sequence: 0,
1168                time,
1169                order_id: order_id.unwrap_or_default(),
1170                side,
1171                price,
1172                quantity: filled_quantity,
1173                position,
1174            });
1175        }
1176        let pending = [OrderSide::Buy, OrderSide::Sell].map(|side| {
1177            self.orders
1178                .iter()
1179                .any(|order| matches!(order.kind, Kind::Entry(entry_side, _) if entry_side == side))
1180        });
1181        self.orders.retain_mut(|order| match &mut order.kind {
1182            Kind::Exit(.., side) => {
1183                *side = side.or(exit_side(position));
1184                side.is_none_or(|side| side.reduces(position))
1185            }
1186            Kind::Entry(..) => true,
1187            Kind::RelativeExit(_, _, side) => {
1188                position * sign(*side) > 0.0 || pending[usize::from(*side == OrderSide::Sell)]
1189            }
1190        });
1191        if self.settings.recalculate_after_fills {
1192            self.decide(price, true);
1193        }
1194    }
1195
1196    /// Close FIFO lots, sharing entry and exit commissions by quantity without charging again.
1197    fn close(
1198        &mut self,
1199        mut quantity: f64,
1200        exit: &str,
1201        price: f64,
1202        fee: f64,
1203        filled_quantity: f64,
1204        time: DateTime<Utc>,
1205    ) {
1206        let exit_quantity = quantity;
1207        while quantity > 0.0
1208            && let Some(lot) = self.lots.first_mut()
1209        {
1210            let closed = quantity.min(lot.quantity);
1211            let entry_fee = if lot.quantity - closed <= rounding_tolerance(lot.entry_quantity, 1e-9)
1212            {
1213                lot.fees
1214            } else {
1215                lot.fees * (closed / lot.quantity)
1216            };
1217            let fees = entry_fee + fee * (closed / filled_quantity);
1218            let gross = sign(lot.side) * (price - lot.price) * closed;
1219            let mut trade = StrategyTrade::new(lot.side, closed, lot.time, lot.price);
1220            trade.exit = Some(StrategyExit::new(time, price, gross - fees + 0.0));
1221            trade.fees = fees;
1222            trade.label = Some(format!("{} / {exit}", lot.entry));
1223            self.trades.push(trade);
1224            self.realized += gross;
1225            quantity -= closed;
1226            lot.quantity -= closed;
1227            lot.fees -= entry_fee;
1228            if quantity.is_finite() && quantity <= rounding_tolerance(exit_quantity, 1e-9) {
1229                quantity = 0.0;
1230            }
1231            if lot.quantity <= rounding_tolerance(lot.entry_quantity, 1e-9) {
1232                self.lots.remove(0);
1233            }
1234        }
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241
1242    /// Places the orders of a script on the bars it names.
1243    struct Script(fn(usize, &mut StrategyContext<'_>));
1244
1245    impl Strategy for Script {
1246        fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
1247            if context.position() == 0.0 {
1248                assert!(context.position().is_sign_positive());
1249            }
1250            (self.0)(context.bars().len() - 1, context);
1251        }
1252    }
1253
1254    fn hour(hour: i64) -> DateTime<Utc> {
1255        DateTime::UNIX_EPOCH + TimeDelta::hours(hour)
1256    }
1257
1258    fn script(bar: usize, context: &mut StrategyContext<'_>) {
1259        use OrderSide::{Buy, Sell};
1260        match bar {
1261            0 => {
1262                context.entry("Long", Buy, 10.0, OrderType::Market);
1263                context.exit("Bracket", None, Some(105.0), Some(98.0));
1264            }
1265            2 => {
1266                let stop_limit = OrderType::StopLimit {
1267                    stop: 102.0,
1268                    limit: 102.5,
1269                };
1270                context.entry("Short", Sell, 5.0, stop_limit);
1271                context.entry("Long2", Buy, 4.0, OrderType::Limit(97.0));
1272            }
1273            3 => context.entry("Long2", Buy, 4.0, OrderType::Limit(100.0)),
1274            4 => {
1275                context.entry("Add", Buy, 2.0, OrderType::Market);
1276                context.exit("Part", Some(5.0), None, None);
1277            }
1278            5 => {
1279                context.exit("Flat", Some(100.0), None, None);
1280                context.entry("Last", Buy, 1.0, OrderType::Market);
1281            }
1282            6 => {
1283                context.exit("Guard", None, Some(110.0), Some(90.0));
1284                context.entry("Flip", Sell, 1.0, OrderType::Market);
1285            }
1286            7 => context.exit("Cover", None, Some(96.0), Some(103.0)),
1287            8 => {
1288                context.entry("Long3", Buy, 1.0, OrderType::Market);
1289                context.exit("Target", None, Some(104.0), Some(90.0));
1290            }
1291            9 => context.exit("Out", None, None, None),
1292            10 => context.entry("Long4", Buy, 1.0, OrderType::Market),
1293            11 => {
1294                context.entry("Reverse", Sell, 1.0, OrderType::Stop(103.0));
1295                context.exit("Stop", None, Some(108.0), Some(103.0));
1296            }
1297            _ => {}
1298        }
1299    }
1300
1301    /// The script with the long's exit placed before the stop and reverse entry on bar 11.
1302    fn stop_first(bar: usize, context: &mut StrategyContext<'_>) {
1303        if bar == 11 {
1304            context.exit("Stop", None, Some(108.0), Some(103.0));
1305        }
1306        script(bar, context);
1307    }
1308
1309    // Worked by hand from the path rule documented on `Backtest`.
1310    #[test]
1311    fn run_fills_along_the_bar_path_and_pairs_trades_first_in_first_out() {
1312        let bars: Vec<_> = [
1313            (100.0, 101.0, 99.0, 100.0),
1314            // Low first: the bracket's stop at 98 and take profit at 105 are not reached.
1315            (101.0, 103.0, 100.0, 102.0),
1316            // High first: the take profit at 105 fills before the low reaches the stop.
1317            (102.0, 106.0, 97.0, 105.0),
1318            // The short's stop triggers at 102 on the way down, its limit fills at 102.5 on the
1319            // way up; the replaced buy limit at 97 is not reached.
1320            (104.0, 105.0, 101.0, 103.0),
1321            // Gap below the buy limit at 100: fills at the open, closing the short first. The
1322            // replaced limit at 97 would fill on the way down.
1323            (98.0, 100.0, 95.0, 99.0),
1324            // At the open, Add fills, then Part closes Long2's 4 and 1 of Add.
1325            (99.0, 101.0, 98.0, 100.0),
1326            // At the open, Flat closes only the remaining 1 of Add, then Last opens.
1327            (100.0, 101.0, 99.0, 100.5),
1328            // At the open, Flip closes Last and opens a short. Guard, placed for Last, is
1329            // cancelled rather than closing the short at once.
1330            (100.0, 101.0, 99.0, 100.0),
1331            // Low first: the short's take profit at 96 fills before the high reaches its stop.
1332            (100.0, 105.0, 96.0, 97.0),
1333            // Long3 opens; Target, placed while flat, protects it.
1334            (97.0, 98.0, 96.0, 97.5),
1335            // Out closes Long3, which cancels Target.
1336            (98.0, 99.0, 97.0, 98.0),
1337            // Long4 opens above Target's take profit at 104 and stays open.
1338            (105.0, 106.0, 104.5, 105.5),
1339            // Low first: at 103 Reverse closes Long4 and opens a short, which cancels Stop, so
1340            // neither its stop at 103 nor its take profit at 108 closes the short.
1341            (105.0, 108.5, 102.0, 102.5),
1342        ]
1343        .into_iter()
1344        .enumerate()
1345        .map(|(i, (open, high, low, close))| Bar::new(hour(i as i64), open, high, low, close, 0.0))
1346        .collect();
1347        let mut backtest = Backtest::new("Script", "TEST", Timeframe::Hour1, 10_000.0);
1348        backtest.settings.pyramiding = usize::MAX;
1349        let report = backtest.run(&mut Script(script), &bars, &[]);
1350
1351        let trade = |side,
1352                     quantity,
1353                     entry: i64,
1354                     entry_price,
1355                     exit: Option<(i64, f64, f64)>,
1356                     label: &str| {
1357            let mut trade = StrategyTrade::new(side, quantity, hour(entry), entry_price);
1358            trade.exit =
1359                exit.map(|(time, price, profit)| StrategyExit::new(hour(time), price, profit));
1360            trade.label = Some(label.to_owned());
1361            trade
1362        };
1363        use OrderSide::{Buy, Sell};
1364        assert_eq!(
1365            report.trades,
1366            [
1367                trade(
1368                    Buy,
1369                    10.0,
1370                    1,
1371                    101.0,
1372                    Some((2, 105.0, 40.0)),
1373                    "Long / Bracket"
1374                ),
1375                trade(Sell, 5.0, 3, 102.5, Some((4, 98.0, 22.5)), "Short / Long2"),
1376                trade(Buy, 4.0, 4, 98.0, Some((5, 99.0, 4.0)), "Long2 / Part"),
1377                trade(Buy, 1.0, 5, 99.0, Some((5, 99.0, 0.0)), "Add / Part"),
1378                trade(Buy, 1.0, 5, 99.0, Some((6, 100.0, 1.0)), "Add / Flat"),
1379                trade(Buy, 1.0, 6, 100.0, Some((7, 100.0, 0.0)), "Last / Flip"),
1380                trade(Sell, 1.0, 7, 100.0, Some((8, 96.0, 4.0)), "Flip / Cover"),
1381                trade(Buy, 1.0, 9, 97.0, Some((10, 98.0, 1.0)), "Long3 / Out"),
1382                trade(
1383                    Buy,
1384                    1.0,
1385                    11,
1386                    105.0,
1387                    Some((12, 103.0, -2.0)),
1388                    "Long4 / Reverse"
1389                ),
1390                trade(Sell, 1.0, 12, 103.0, None, "Reverse"),
1391            ]
1392        );
1393        let equity = [
1394            10_000.0, 10_000.0, 10_010.0, 10_040.0, 10_037.5, 10_066.5, 10_067.5, 10_068.0,
1395            10_067.5, 10_071.5, 10_072.0, 10_072.5, 10_073.0, 10_071.0,
1396        ];
1397        let times = std::iter::once(hour(-1)).chain((0..13).map(hour));
1398        assert_eq!(report.equity, times.zip(equity).collect::<Vec<_>>());
1399        assert_eq!(report.equity_curve(), report.equity);
1400        // Closed profits: 40 + 22.5 + 4 + 0 + 1 + 0 + 4 + 1 - 2 = 70.5.
1401        let metrics = report.performance();
1402        assert_eq!(
1403            (
1404                metrics.closed_trades,
1405                metrics.open_trades,
1406                metrics.skipped_trades
1407            ),
1408            (9, 1, 0)
1409        );
1410        assert_eq!((metrics.winning_trades, metrics.losing_trades), (6, 1));
1411        assert_eq!(
1412            (metrics.net_profit, metrics.gross_profit, metrics.gross_loss),
1413            (70.5, 72.5, 2.0)
1414        );
1415        assert_eq!(metrics.win_rate, Some(6.0 / 9.0));
1416        assert_eq!(metrics.profit_factor, Some(36.25));
1417
1418        // Without the later bars the run sees and does the same up to its last bar.
1419        let first = backtest.run(&mut Script(script), &bars[..4], &[]);
1420        assert_eq!(first.equity, report.equity[..5]);
1421        assert_eq!(first.trades[0], report.trades[0]);
1422        assert_eq!(first.trades[1], trade(Sell, 5.0, 3, 102.5, None, "Short"));
1423
1424        // Placing the long's exit first only changes which of the two closes the long at 103.
1425        let stop_first = backtest.run(&mut Script(stop_first), &bars, &[]);
1426        assert_eq!(stop_first.equity, report.equity);
1427        let mut trades = report.trades.clone();
1428        trades[8].label = Some("Long4 / Stop".to_owned());
1429        assert_eq!(stop_first.trades, trades);
1430
1431        // On close, the first market entry fills at the close of the bar it was placed on.
1432        let mut on_close = backtest.clone();
1433        on_close.settings.fill_on_close = true;
1434        let on_close = on_close.run(&mut Script(script), &bars, &[]);
1435        assert_eq!(
1436            on_close.trades[0],
1437            trade(
1438                Buy,
1439                10.0,
1440                0,
1441                100.0,
1442                Some((2, 105.0, 50.0)),
1443                "Long / Bracket"
1444            )
1445        );
1446
1447        fn decimals(bar: usize, context: &mut StrategyContext<'_>) {
1448            match bar {
1449                0 => {
1450                    context.entry("A", Buy, 0.1, OrderType::Market);
1451                    context.entry("B", Buy, 0.2, OrderType::Market);
1452                }
1453                1 => context.exit("X", Some(0.3), None, None),
1454                _ => {}
1455            }
1456        }
1457        fn thirds(bar: usize, context: &mut StrategyContext<'_>) {
1458            match bar {
1459                0 => context.entry("A", Buy, 0.3, OrderType::Market),
1460                1 => context.entry("B", Buy, 0.5, OrderType::Market),
1461                2 => context.exit("X1", Some(0.1), None, None),
1462                3 => context.exit("X2", Some(0.1), None, None),
1463                4 => context.exit("X3", Some(0.1), None, None),
1464                _ => {}
1465            }
1466        }
1467        fn tenths(bar: usize, context: &mut StrategyContext<'_>) {
1468            match bar {
1469                0 => context.entry("A", Buy, 1.0, OrderType::Market),
1470                1..=10 => context.exit("X", Some(0.1), None, None),
1471                11 => assert_eq!(context.position(), 0.0),
1472                _ => {}
1473            }
1474        }
1475        fn pyramided_scale_out(bar: usize, context: &mut StrategyContext<'_>) {
1476            match bar {
1477                0 => {
1478                    context.entry("A", Buy, 1.4, OrderType::Market);
1479                    context.entry("B", Buy, 3.2, OrderType::Market);
1480                }
1481                1..=14 => context.exit("X", Some(0.1), None, None),
1482                15..=78 => context.exit("X", Some(0.05), None, None),
1483                79 => assert_eq!(context.position(), 0.0),
1484                _ => {}
1485            }
1486        }
1487        fn decimal_tie(bar: usize, context: &mut StrategyContext<'_>) {
1488            if bar == 0 {
1489                context.entry("L", Buy, 1.0, OrderType::Market);
1490                context.exit("B", None, Some(1.2), Some(1.1));
1491            }
1492        }
1493        fn invalid_range(bar: usize, context: &mut StrategyContext<'_>) {
1494            if bar == 0 {
1495                context.entry("L", Buy, 1.0, OrderType::Limit(104.0));
1496            }
1497        }
1498        fn short_breakeven(bar: usize, context: &mut StrategyContext<'_>) {
1499            match bar {
1500                0 => context.entry("S", Sell, 1.0, OrderType::Market),
1501                1 => context.exit("X", None, None, None),
1502                _ => {}
1503            }
1504        }
1505        fn low_first_stop(bar: usize, context: &mut StrategyContext<'_>) {
1506            match bar {
1507                0 => context.entry("L", Buy, 1.0, OrderType::Market),
1508                1 => context.exit("B", None, Some(104.0), Some(98.0)),
1509                _ => {}
1510            }
1511        }
1512        fn short_stop(bar: usize, context: &mut StrategyContext<'_>) {
1513            match bar {
1514                0 => context.entry("S", Sell, 1.0, OrderType::Market),
1515                1 => context.exit("B", None, Some(97.0), Some(101.0)),
1516                _ => {}
1517            }
1518        }
1519        fn stop_limit_gap(bar: usize, context: &mut StrategyContext<'_>) {
1520            if bar == 0 {
1521                context.entry(
1522                    "G",
1523                    Buy,
1524                    1.0,
1525                    OrderType::StopLimit {
1526                        stop: 102.0,
1527                        limit: 103.0,
1528                    },
1529                );
1530            }
1531        }
1532        fn replaced_first(bar: usize, context: &mut StrategyContext<'_>) {
1533            if bar == 0 {
1534                context.entry("A", Buy, 1.0, OrderType::Market);
1535                context.entry("B", Buy, 1.0, OrderType::Market);
1536                context.exit("X", Some(1.0), None, None);
1537                context.entry("A", Buy, 2.0, OrderType::Market);
1538            }
1539        }
1540        // Decimal lots close at their entry price: two breakevens, flat equity and no open lot.
1541        // Thirds realise 3 * 0.1 * (102 - 100) = 0.6, all winners. B keeps 0.5 at 104,
1542        // so its mark at 102 is -1 and final equity is 9999.6. No fourth closed trade.
1543        // Decimal tie: high first closes 1 long at 1.2 from 1.15, profit 0.05, one winner.
1544        // Invalid open and close bars do not fill or value the account. The next valid bar
1545        // opens the long at 103, with no closed trades or realised profit and equity 10000.
1546        for (script, prices, expected, equity, closed, open, wins, profit) in [
1547            (
1548                decimals as fn(usize, &mut StrategyContext<'_>),
1549                vec![(100.0, 100.0, 100.0, 100.0); 4],
1550                vec![
1551                    trade(Buy, 0.1, 1, 100.0, Some((2, 100.0, 0.0)), "A / X"),
1552                    trade(Buy, 0.2, 1, 100.0, Some((2, 100.0, 0.0)), "B / X"),
1553                ],
1554                vec![10_000.0; 5],
1555                2,
1556                0,
1557                0,
1558                0.0,
1559            ),
1560            (
1561                thirds,
1562                [100.0, 100.0, 104.0, 102.0, 102.0, 102.0]
1563                    .into_iter()
1564                    .map(|p| (p, p, p, p))
1565                    .collect(),
1566                vec![
1567                    trade(Buy, 0.1, 1, 100.0, Some((3, 102.0, 0.2)), "A / X1"),
1568                    trade(Buy, 0.1, 1, 100.0, Some((4, 102.0, 0.2)), "A / X2"),
1569                    trade(Buy, 0.1, 1, 100.0, Some((5, 102.0, 0.2)), "A / X3"),
1570                    trade(Buy, 0.5, 2, 104.0, None, "B"),
1571                ],
1572                vec![
1573                    10_000.0, 10_000.0, 10_000.0, 10_001.2, 9999.6, 9999.6, 9999.6,
1574                ],
1575                3,
1576                1,
1577                3,
1578                0.6,
1579            ),
1580            (
1581                // 1 - 10 * 0.1 = 0: ten breakevens, flat equity and no open lot.
1582                tenths,
1583                vec![(100.0, 100.0, 100.0, 100.0); 12],
1584                (2..=11)
1585                    .map(|exit| trade(Buy, 0.1, 1, 100.0, Some((exit, 100.0, 0.0)), "A / X"))
1586                    .collect(),
1587                vec![10_000.0; 13],
1588                10,
1589                0,
1590                0,
1591                0.0,
1592            ),
1593            (
1594                // 1.4 - 14 * 0.1 = 0, 3.2 - 64 * 0.05 = 0: 78 breakevens and flat equity.
1595                pyramided_scale_out,
1596                vec![(100.0, 100.0, 100.0, 100.0); 80],
1597                (2..=79)
1598                    .map(|exit| {
1599                        let (quantity, label) = if exit <= 15 {
1600                            (0.1, "A / X")
1601                        } else {
1602                            (0.05, "B / X")
1603                        };
1604                        trade(Buy, quantity, 1, 100.0, Some((exit, 100.0, 0.0)), label)
1605                    })
1606                    .collect(),
1607                vec![10_000.0; 81],
1608                78,
1609                0,
1610                0,
1611                0.0,
1612            ),
1613            (
1614                decimal_tie,
1615                vec![
1616                    (1.15, 1.16, 1.14, 1.15),
1617                    (1.15, 1.16, 1.14, 1.15),
1618                    (1.15, 1.2, 1.1, 1.15),
1619                ],
1620                vec![trade(Buy, 1.0, 1, 1.15, Some((2, 1.2, 0.05)), "L / B")],
1621                vec![10_000.0, 10_000.0, 10_000.0, 10_000.05],
1622                1,
1623                0,
1624                1,
1625                0.05,
1626            ),
1627            (
1628                invalid_range,
1629                vec![
1630                    (100.0, 101.0, 99.0, 100.0),
1631                    (105.0, 101.0, 99.0, 100.0),
1632                    (100.0, 101.0, 99.0, 102.0),
1633                    (103.0, 104.0, 102.0, 103.0),
1634                ],
1635                vec![trade(Buy, 1.0, 3, 103.0, None, "L")],
1636                vec![10_000.0, 10_000.0, f64::NAN, f64::NAN, 10_000.0],
1637                0,
1638                1,
1639                0,
1640                0.0,
1641            ),
1642            // A short closed at entry breaks even: positive zero profit, flat equity.
1643            (
1644                short_breakeven,
1645                vec![(100.0, 100.0, 100.0, 100.0); 3],
1646                vec![trade(Sell, 1.0, 1, 100.0, Some((2, 100.0, 0.0)), "S / X")],
1647                vec![10_000.0; 4],
1648                1,
1649                0,
1650                0,
1651                0.0,
1652            ),
1653            // Low first: the long's raised stop wins before its target. Profit = 98 - 90 = 8.
1654            (
1655                low_first_stop,
1656                vec![(90.0, 90.0, 90.0, 90.0); 2]
1657                    .into_iter()
1658                    .chain([(100.0, 105.0, 97.0, 100.0)])
1659                    .collect(),
1660                vec![trade(Buy, 1.0, 1, 90.0, Some((2, 98.0, 8.0)), "L / B")],
1661                vec![10_000.0, 10_000.0, 10_000.0, 10_008.0],
1662                1,
1663                0,
1664                1,
1665                8.0,
1666            ),
1667            // High first: the short's lowered stop wins before its target. Profit = 110 - 101 = 9.
1668            (
1669                short_stop,
1670                vec![(110.0, 110.0, 110.0, 110.0); 2]
1671                    .into_iter()
1672                    .chain([(100.0, 102.0, 96.0, 100.0)])
1673                    .collect(),
1674                vec![trade(Sell, 1.0, 1, 110.0, Some((2, 101.0, 9.0)), "S / B")],
1675                vec![10_000.0, 10_000.0, 10_000.0, 10_009.0],
1676                1,
1677                0,
1678                1,
1679                9.0,
1680            ),
1681            // Empty and one-bar runs cannot fill next-bar orders.
1682            (decimals, vec![], vec![], vec![], 0, 0, 0, 0.0),
1683            (
1684                decimals,
1685                vec![(100.0, 100.0, 100.0, 100.0)],
1686                vec![],
1687                vec![10_000.0; 2],
1688                0,
1689                0,
1690                0,
1691                0.0,
1692            ),
1693            // A NaN bar keeps the limit pending, just like an invalid range.
1694            (
1695                invalid_range,
1696                vec![
1697                    (100.0, 101.0, 99.0, 100.0),
1698                    (100.0, f64::NAN, 99.0, 100.0),
1699                    (103.0, 104.0, 102.0, 103.0),
1700                ],
1701                vec![trade(Buy, 1.0, 2, 103.0, None, "L")],
1702                vec![10_000.0, 10_000.0, f64::NAN, 10_000.0],
1703                0,
1704                1,
1705                0,
1706                0.0,
1707            ),
1708            // A gap triggers the stop but cannot fill above the limit. The next bar fills
1709            // at 103 and marks at 104: one open lot, equity +1 and no realised profit.
1710            (
1711                stop_limit_gap,
1712                vec![
1713                    (100.0, 100.0, 100.0, 100.0),
1714                    (105.0, 106.0, 104.0, 105.0),
1715                    (104.0, 105.0, 102.0, 104.0),
1716                ],
1717                vec![trade(Buy, 1.0, 2, 103.0, None, "G")],
1718                vec![10_000.0, 10_000.0, 10_000.0, 10_001.0],
1719                0,
1720                1,
1721                0,
1722                0.0,
1723            ),
1724            // Replaced A still precedes B and X. X closes 1 of A on the entry bar;
1725            // the other 1 of A and 1 of B remain, with flat equity and a breakeven close.
1726            (
1727                replaced_first,
1728                vec![(100.0, 100.0, 100.0, 100.0); 2],
1729                vec![
1730                    trade(Buy, 1.0, 1, 100.0, Some((1, 100.0, 0.0)), "A / X"),
1731                    trade(Buy, 1.0, 1, 100.0, None, "A"),
1732                    trade(Buy, 1.0, 1, 100.0, None, "B"),
1733                ],
1734                vec![10_000.0; 3],
1735                1,
1736                2,
1737                0,
1738                0.0,
1739            ),
1740        ] {
1741            let source: Vec<_> = prices
1742                .into_iter()
1743                .enumerate()
1744                .map(|(i, (open, high, low, close))| {
1745                    Bar::new(hour(i as i64), open, high, low, close, 0.0)
1746                })
1747                .collect();
1748            let report = backtest.run(&mut Script(script), &source, &[]);
1749            assert_eq!(report.trades.len(), expected.len());
1750            for (mut actual, expected) in report.trades.iter().cloned().zip(expected) {
1751                assert!((actual.quantity - expected.quantity).abs() < 1e-15);
1752                actual.quantity = expected.quantity;
1753                if let (Some(actual), Some(expected)) = (&mut actual.exit, &expected.exit) {
1754                    assert!((actual.profit - expected.profit).abs() < 1e-15);
1755                    if actual.profit == 0.0 {
1756                        assert!(actual.profit.is_sign_positive());
1757                    }
1758                    actual.profit = expected.profit;
1759                }
1760                assert_eq!(actual, expected);
1761            }
1762            assert_eq!(report.equity.len(), equity.len());
1763            let skipped = equity.iter().filter(|value| value.is_nan()).count();
1764            for ((time, actual), (expected_time, expected)) in report
1765                .equity
1766                .iter()
1767                .zip((-1..source.len() as i64).map(hour).zip(equity))
1768            {
1769                assert_eq!(*time, expected_time);
1770                assert!(*actual == expected || (actual.is_nan() && expected.is_nan()));
1771            }
1772            assert_eq!(report.performance().skipped_equity_points, skipped);
1773            assert_eq!(report.comparison(&source).skipped_bars, skipped);
1774            let metrics = report.performance();
1775            assert_eq!(
1776                (
1777                    metrics.closed_trades,
1778                    metrics.open_trades,
1779                    metrics.winning_trades,
1780                    metrics.losing_trades
1781                ),
1782                (closed, open, wins, 0)
1783            );
1784            assert!((metrics.net_profit - profit).abs() < 1e-15);
1785            assert_eq!(
1786                metrics.win_rate,
1787                (closed > 0).then(|| wins as f64 / closed as f64)
1788            );
1789            assert_eq!(metrics.profit_factor, None);
1790            #[cfg(feature = "csv")]
1791            assert!(!report.trades_csv().contains(",-0"));
1792        }
1793
1794        fn sized(bar: usize, context: &mut StrategyContext<'_>) {
1795            match bar {
1796                0 => context.entry("A", Buy, None, OrderType::Market),
1797                1 => context.exit("X", None, None, None),
1798                _ => {}
1799            }
1800        }
1801        fn override_size(bar: usize, context: &mut StrategyContext<'_>) {
1802            if bar == 0 {
1803                context.entry("A", Buy, 4.0, OrderType::Market);
1804            } else if bar == 1 {
1805                context.exit("X", None, None, None);
1806            }
1807        }
1808        fn repeat(bar: usize, context: &mut StrategyContext<'_>) {
1809            match bar {
1810                0 => context.entry("A", Buy, None, OrderType::Market),
1811                1 => context.exit("X", None, None, None),
1812                2 => context.entry("B", Buy, None, OrderType::Market),
1813                _ => {}
1814            }
1815        }
1816        fn stack_in_time(bar: usize, context: &mut StrategyContext<'_>) {
1817            match bar {
1818                0 => context.entry("A", Buy, None, OrderType::Market),
1819                1 => context.entry("B", Buy, None, OrderType::Market),
1820                _ => {}
1821            }
1822        }
1823        fn stack(bar: usize, context: &mut StrategyContext<'_>) {
1824            if bar == 0 {
1825                context.entry("A", Buy, None, OrderType::Market);
1826                context.entry("B", Buy, None, OrderType::Market);
1827            } else if bar == 1 {
1828                context.entry("C", Buy, None, OrderType::Market);
1829            }
1830        }
1831        fn hold(bar: usize, context: &mut StrategyContext<'_>) {
1832            if bar == 0 {
1833                context.entry("A", Buy, None, OrderType::Market);
1834            }
1835        }
1836        fn hold_short(bar: usize, context: &mut StrategyContext<'_>) {
1837            if bar == 0 {
1838                context.entry("A", Sell, None, OrderType::Market);
1839            }
1840        }
1841        fn split(bar: usize, context: &mut StrategyContext<'_>) {
1842            if bar == 0 {
1843                context.entry("A", Buy, 1.0, OrderType::Market);
1844                context.entry("B", Buy, 3.0, OrderType::Market);
1845            } else if bar == 1 {
1846                context.exit("X", Some(2.0), None, None);
1847            }
1848        }
1849        fn reverse(bar: usize, context: &mut StrategyContext<'_>) {
1850            match bar {
1851                0 => context.entry("A", Buy, 2.0, OrderType::Market),
1852                1 => context.entry("B", Sell, 3.0, OrderType::Market),
1853                2 => context.exit("X", None, None, None),
1854                _ => {}
1855            }
1856        }
1857        fn stops(bar: usize, context: &mut StrategyContext<'_>) {
1858            if bar == 0 {
1859                context.entry("A", Buy, None, OrderType::Stop(105.0));
1860                context.exit("X", None, None, Some(95.0));
1861            }
1862        }
1863        fn limits(bar: usize, context: &mut StrategyContext<'_>) {
1864            if bar == 0 {
1865                context.entry("A", Buy, None, OrderType::Limit(100.0));
1866                context.exit("X", None, Some(110.0), None);
1867            }
1868        }
1869        fn recalculate(bar: usize, context: &mut StrategyContext<'_>) {
1870            if context.is_after_fill() {
1871                assert_eq!(
1872                    context.bars().len(),
1873                    1,
1874                    "current bar must not leak future prices"
1875                );
1876                if context.position() > 0.0 {
1877                    assert_eq!(context.price(), 100.0);
1878                    assert_eq!(context.equity(), 1000.0);
1879                    context.exit("X", None, Some(context.price() + 5.0), Some(98.0));
1880                } else {
1881                    assert_eq!(context.price(), 105.0);
1882                }
1883            } else if bar == 0 {
1884                context.entry("A", Buy, None, OrderType::Market);
1885            }
1886        }
1887        fn cycle(bar: usize, context: &mut StrategyContext<'_>) {
1888            if context.is_after_fill() {
1889                if context.position() > 0.0 {
1890                    context.exit("X", None, None, None);
1891                } else {
1892                    context.entry("A", Buy, None, OrderType::Market);
1893                }
1894            } else if bar == 0 {
1895                context.entry("A", Buy, None, OrderType::Market);
1896            }
1897        }
1898        fn sized_short(bar: usize, context: &mut StrategyContext<'_>) {
1899            if bar == 0 {
1900                context.entry("A", Sell, None, OrderType::Market);
1901            } else if bar == 1 {
1902                context.exit("X", None, None, None);
1903            }
1904        }
1905        fn stop_limits(bar: usize, context: &mut StrategyContext<'_>) {
1906            if bar == 0 {
1907                context.entry(
1908                    "A",
1909                    Buy,
1910                    None,
1911                    OrderType::StopLimit {
1912                        stop: 105.0,
1913                        limit: 106.0,
1914                    },
1915                );
1916                context.exit("X", None, Some(110.0), None);
1917            }
1918        }
1919        fn boundary_stop(bar: usize, context: &mut StrategyContext<'_>) {
1920            match bar {
1921                0 => context.entry("A", Buy, None, OrderType::Market),
1922                1 => context.exit("X", None, None, Some(100.0)),
1923                _ => {}
1924            }
1925        }
1926        let terms = BacktestSettings {
1927            initial_capital: 1000.0,
1928            currency: Some("USD".into()),
1929            risk_free_rate: 0.02,
1930            order_size: OrderSize::Fixed(2.0),
1931            ..Default::default()
1932        };
1933        let flat = |prices: &[f64]| prices.iter().map(|&p| (p, p, p, p)).collect::<Vec<_>>();
1934        let fee_trade = |side, quantity, entry, entry_price, exit, fees, label| {
1935            let mut result = trade(side, quantity, entry, entry_price, exit, label);
1936            result.fees = fees;
1937            result
1938        };
1939        // All amounts are in account units. For example, 2 units from 100 to 110 earn 20;
1940        // a 1% fee costs 2 + 2.2, and two ticks of 0.5 reduce that gross profit to 16.
1941        // At 2x leverage, 15 long from 100 have equity 15p-500 and margin 7.5p:
1942        // the call is at p=200/3, equity=500, even if the bar later recovers to 100.
1943        for (name, script, settings, prices, expected, equity, rejected) in [
1944            (
1945                "fixed size",
1946                sized as fn(usize, &mut StrategyContext<'_>),
1947                terms.clone(),
1948                flat(&[100.0, 100.0, 110.0]),
1949                vec![trade(Buy, 2.0, 1, 100.0, Some((2, 110.0, 20.0)), "A / X")],
1950                vec![1000.0, 1000.0, 1000.0, 1020.0],
1951                0,
1952            ),
1953            (
1954                "cash size",
1955                sized,
1956                BacktestSettings {
1957                    order_size: OrderSize::Cash(200.0),
1958                    ..terms.clone()
1959                },
1960                flat(&[100.0, 125.0, 130.0]),
1961                vec![trade(Buy, 1.6, 1, 125.0, Some((2, 130.0, 8.0)), "A / X")],
1962                vec![1000.0, 1000.0, 1000.0, 1008.0],
1963                0,
1964            ),
1965            (
1966                "equity size",
1967                sized,
1968                BacktestSettings {
1969                    order_size: OrderSize::EquityFraction(0.5),
1970                    ..terms.clone()
1971                },
1972                flat(&[100.0, 100.0, 110.0]),
1973                vec![trade(Buy, 5.0, 1, 100.0, Some((2, 110.0, 50.0)), "A / X")],
1974                vec![1000.0, 1000.0, 1000.0, 1050.0],
1975                0,
1976            ),
1977            (
1978                "current equity size",
1979                repeat,
1980                BacktestSettings {
1981                    order_size: OrderSize::EquityFraction(0.5),
1982                    ..terms.clone()
1983                },
1984                flat(&[100.0, 100.0, 120.0, 100.0, 110.0]),
1985                vec![
1986                    trade(Buy, 5.0, 1, 100.0, Some((2, 120.0, 100.0)), "A / X"),
1987                    trade(Buy, 5.5, 3, 100.0, None, "B"),
1988                ],
1989                vec![1000.0, 1000.0, 1000.0, 1100.0, 1100.0, 1155.0],
1990                0,
1991            ),
1992            // At the second fill equity is 1000+5*(120-100)=1100, so half buys 550/120.
1993            (
1994                "equity sizing marks existing lots",
1995                stack_in_time,
1996                BacktestSettings {
1997                    order_size: OrderSize::EquityFraction(0.5),
1998                    pyramiding: 2,
1999                    leverage: 2.0,
2000                    ..terms.clone()
2001                },
2002                flat(&[100.0, 100.0, 120.0]),
2003                vec![
2004                    trade(Buy, 5.0, 1, 100.0, None, "A"),
2005                    trade(Buy, 55.0 / 12.0, 2, 120.0, None, "B"),
2006                ],
2007                vec![1000.0, 1000.0, 1000.0, 1100.0],
2008                0,
2009            ),
2010            (
2011                "cash cannot size at zero",
2012                hold,
2013                BacktestSettings {
2014                    order_size: OrderSize::Cash(200.0),
2015                    ..terms.clone()
2016                },
2017                flat(&[100.0, 0.0, 0.0]),
2018                vec![],
2019                vec![1000.0; 4],
2020                1,
2021            ),
2022            (
2023                "quantity override",
2024                override_size,
2025                terms.clone(),
2026                flat(&[100.0, 100.0, 110.0]),
2027                vec![trade(Buy, 4.0, 1, 100.0, Some((2, 110.0, 40.0)), "A / X")],
2028                vec![1000.0, 1000.0, 1000.0, 1040.0],
2029                0,
2030            ),
2031            (
2032                "default pyramiding",
2033                stack,
2034                terms.clone(),
2035                flat(&[100.0, 100.0, 110.0]),
2036                vec![trade(Buy, 2.0, 1, 100.0, None, "A")],
2037                vec![1000.0, 1000.0, 1000.0, 1020.0],
2038                0,
2039            ),
2040            (
2041                "two entries",
2042                stack,
2043                BacktestSettings {
2044                    pyramiding: 2,
2045                    ..terms.clone()
2046                },
2047                flat(&[100.0, 100.0, 110.0]),
2048                vec![
2049                    trade(Buy, 2.0, 1, 100.0, None, "A"),
2050                    trade(Buy, 2.0, 1, 100.0, None, "B"),
2051                ],
2052                vec![1000.0, 1000.0, 1000.0, 1040.0],
2053                0,
2054            ),
2055            (
2056                "disabled entries",
2057                stack,
2058                BacktestSettings {
2059                    pyramiding: 0,
2060                    ..terms.clone()
2061                },
2062                flat(&[100.0, 100.0, 110.0]),
2063                vec![],
2064                vec![1000.0; 4],
2065                0,
2066            ),
2067            (
2068                "fraction commission",
2069                sized,
2070                BacktestSettings {
2071                    commission: Commission::Fraction(0.01),
2072                    ..terms.clone()
2073                },
2074                flat(&[100.0, 100.0, 110.0]),
2075                vec![fee_trade(
2076                    Buy,
2077                    2.0,
2078                    1,
2079                    100.0,
2080                    Some((2, 110.0, 15.8)),
2081                    4.2,
2082                    "A / X",
2083                )],
2084                vec![1000.0, 1000.0, 998.0, 1015.8],
2085                0,
2086            ),
2087            (
2088                "unit commission",
2089                sized,
2090                BacktestSettings {
2091                    commission: Commission::PerUnit(0.5),
2092                    ..terms.clone()
2093                },
2094                flat(&[100.0, 100.0, 110.0]),
2095                vec![fee_trade(
2096                    Buy,
2097                    2.0,
2098                    1,
2099                    100.0,
2100                    Some((2, 110.0, 18.0)),
2101                    2.0,
2102                    "A / X",
2103                )],
2104                vec![1000.0, 1000.0, 999.0, 1018.0],
2105                0,
2106            ),
2107            (
2108                "order commission",
2109                sized,
2110                BacktestSettings {
2111                    commission: Commission::PerOrder(2.0),
2112                    ..terms.clone()
2113                },
2114                flat(&[100.0, 100.0, 110.0]),
2115                vec![fee_trade(
2116                    Buy,
2117                    2.0,
2118                    1,
2119                    100.0,
2120                    Some((2, 110.0, 16.0)),
2121                    4.0,
2122                    "A / X",
2123                )],
2124                vec![1000.0, 1000.0, 998.0, 1016.0],
2125                0,
2126            ),
2127            (
2128                "fees shared across FIFO lots",
2129                split,
2130                BacktestSettings {
2131                    commission: Commission::PerOrder(4.0),
2132                    pyramiding: 2,
2133                    ..terms.clone()
2134                },
2135                flat(&[100.0, 100.0, 110.0]),
2136                vec![
2137                    fee_trade(Buy, 1.0, 1, 100.0, Some((2, 110.0, 4.0)), 6.0, "A / X"),
2138                    fee_trade(
2139                        Buy,
2140                        1.0,
2141                        1,
2142                        100.0,
2143                        Some((2, 110.0, 20.0 / 3.0)),
2144                        10.0 / 3.0,
2145                        "B / X",
2146                    ),
2147                    fee_trade(Buy, 2.0, 1, 100.0, None, 8.0 / 3.0, "B"),
2148                ],
2149                vec![1000.0, 1000.0, 992.0, 1028.0],
2150                0,
2151            ),
2152            (
2153                "reversal is one fill",
2154                reverse,
2155                BacktestSettings {
2156                    commission: Commission::PerOrder(4.0),
2157                    ..terms.clone()
2158                },
2159                flat(&[100.0, 100.0, 110.0, 100.0]),
2160                vec![
2161                    fee_trade(Buy, 2.0, 1, 100.0, Some((2, 110.0, 14.4)), 5.6, "A / B"),
2162                    fee_trade(Sell, 3.0, 2, 110.0, Some((3, 100.0, 23.6)), 6.4, "B / X"),
2163                ],
2164                vec![1000.0, 1000.0, 996.0, 1012.0, 1038.0],
2165                0,
2166            ),
2167            (
2168                "tick slippage",
2169                sized,
2170                BacktestSettings {
2171                    slippage: Slippage::Ticks {
2172                        ticks: 2,
2173                        tick_size: 0.5,
2174                    },
2175                    ..terms.clone()
2176                },
2177                flat(&[100.0, 100.0, 110.0]),
2178                vec![trade(Buy, 2.0, 1, 101.0, Some((2, 109.0, 16.0)), "A / X")],
2179                vec![1000.0, 1000.0, 998.0, 1016.0],
2180                0,
2181            ),
2182            (
2183                "fraction slippage",
2184                sized,
2185                BacktestSettings {
2186                    slippage: Slippage::Fraction(0.01),
2187                    ..terms.clone()
2188                },
2189                flat(&[100.0, 100.0, 110.0]),
2190                vec![trade(Buy, 2.0, 1, 101.0, Some((2, 108.9, 15.8)), "A / X")],
2191                vec![1000.0, 1000.0, 998.0, 1015.8],
2192                0,
2193            ),
2194            (
2195                "stop slippage",
2196                stops,
2197                BacktestSettings {
2198                    slippage: Slippage::Ticks {
2199                        ticks: 2,
2200                        tick_size: 0.5,
2201                    },
2202                    ..terms.clone()
2203                },
2204                vec![
2205                    (100.0, 100.0, 100.0, 100.0),
2206                    (100.0, 110.0, 99.0, 108.0),
2207                    (108.0, 109.0, 90.0, 100.0),
2208                ],
2209                vec![trade(Buy, 2.0, 1, 106.0, Some((2, 94.0, -24.0)), "A / X")],
2210                vec![1000.0, 1000.0, 1004.0, 976.0],
2211                0,
2212            ),
2213            (
2214                "limits never slip",
2215                limits,
2216                BacktestSettings {
2217                    slippage: Slippage::Fraction(0.1),
2218                    ..terms.clone()
2219                },
2220                vec![
2221                    (100.0, 100.0, 100.0, 100.0),
2222                    (101.0, 103.0, 99.0, 102.0),
2223                    (108.0, 111.0, 107.0, 110.0),
2224                ],
2225                vec![trade(Buy, 2.0, 1, 100.0, Some((2, 110.0, 20.0)), "A / X")],
2226                vec![1000.0, 1000.0, 1004.0, 1020.0],
2227                0,
2228            ),
2229            (
2230                "insufficient margin",
2231                hold,
2232                BacktestSettings {
2233                    order_size: OrderSize::Fixed(11.0),
2234                    ..terms.clone()
2235                },
2236                flat(&[100.0, 100.0, 110.0]),
2237                vec![],
2238                vec![1000.0; 4],
2239                1,
2240            ),
2241            (
2242                "commission enters buying power",
2243                hold,
2244                BacktestSettings {
2245                    order_size: OrderSize::Fixed(10.0),
2246                    commission: Commission::PerOrder(1.0),
2247                    ..terms.clone()
2248                },
2249                flat(&[100.0, 100.0, 110.0]),
2250                vec![],
2251                vec![1000.0; 4],
2252                1,
2253            ),
2254            (
2255                "leverage",
2256                hold,
2257                BacktestSettings {
2258                    order_size: OrderSize::Fixed(15.0),
2259                    leverage: 2.0,
2260                    ..terms.clone()
2261                },
2262                flat(&[100.0, 100.0, 110.0]),
2263                vec![trade(Buy, 15.0, 1, 100.0, None, "A")],
2264                vec![1000.0, 1000.0, 1000.0, 1150.0],
2265                0,
2266            ),
2267            (
2268                "stop at the margin boundary averts a call",
2269                boundary_stop,
2270                BacktestSettings {
2271                    order_size: OrderSize::Fixed(20.0),
2272                    leverage: 2.0,
2273                    ..terms.clone()
2274                },
2275                vec![
2276                    (100.0, 100.0, 100.0, 100.0),
2277                    (100.0, 100.0, 100.0, 100.0),
2278                    (101.0, 102.0, 90.0, 100.0),
2279                ],
2280                vec![trade(Buy, 20.0, 1, 100.0, Some((2, 100.0, 0.0)), "A / X")],
2281                vec![1000.0; 4],
2282                0,
2283            ),
2284            (
2285                "an existing margin deficit wins a stop tie",
2286                boundary_stop,
2287                BacktestSettings {
2288                    order_size: OrderSize::Fixed(20.0),
2289                    leverage: 2.0,
2290                    ..terms.clone()
2291                },
2292                flat(&[100.0, 100.0, 90.0]),
2293                vec![trade(
2294                    Buy,
2295                    20.0,
2296                    1,
2297                    100.0,
2298                    Some((2, 90.0, -200.0)),
2299                    "A / Margin call",
2300                )],
2301                vec![1000.0, 1000.0, 1000.0, 800.0],
2302                0,
2303            ),
2304            (
2305                "intrabar long margin call",
2306                hold,
2307                BacktestSettings {
2308                    order_size: OrderSize::Fixed(15.0),
2309                    leverage: 2.0,
2310                    ..terms.clone()
2311                },
2312                vec![
2313                    (100.0, 100.0, 100.0, 100.0),
2314                    (100.0, 100.0, 100.0, 100.0),
2315                    (100.0, 105.0, 50.0, 100.0),
2316                ],
2317                vec![trade(
2318                    Buy,
2319                    15.0,
2320                    1,
2321                    100.0,
2322                    Some((2, 200.0 / 3.0, -500.0)),
2323                    "A / Margin call",
2324                )],
2325                vec![1000.0, 1000.0, 1000.0, 500.0],
2326                0,
2327            ),
2328            (
2329                "intrabar short margin call",
2330                hold_short,
2331                BacktestSettings {
2332                    order_size: OrderSize::Fixed(10.0),
2333                    leverage: 2.0,
2334                    ..terms.clone()
2335                },
2336                vec![
2337                    (100.0, 100.0, 100.0, 100.0),
2338                    (100.0, 100.0, 100.0, 100.0),
2339                    (100.0, 150.0, 95.0, 100.0),
2340                ],
2341                vec![trade(
2342                    Sell,
2343                    10.0,
2344                    1,
2345                    100.0,
2346                    Some((2, 400.0 / 3.0, -1000.0 / 3.0)),
2347                    "A / Margin call",
2348                )],
2349                vec![1000.0, 1000.0, 1000.0, 2000.0 / 3.0],
2350                0,
2351            ),
2352            (
2353                "gap margin call",
2354                hold,
2355                BacktestSettings {
2356                    order_size: OrderSize::Fixed(15.0),
2357                    leverage: 2.0,
2358                    ..terms.clone()
2359                },
2360                flat(&[100.0, 100.0, 50.0]),
2361                vec![trade(
2362                    Buy,
2363                    15.0,
2364                    1,
2365                    100.0,
2366                    Some((2, 50.0, -750.0)),
2367                    "A / Margin call",
2368                )],
2369                vec![1000.0, 1000.0, 1000.0, 250.0],
2370                0,
2371            ),
2372            // Rescaling prices by 1e-200 and units by 1e200 preserves every cash amount.
2373            (
2374                "margin crossings do not multiply tiny prices",
2375                hold,
2376                BacktestSettings {
2377                    order_size: OrderSize::Fixed(5e200),
2378                    leverage: 2.0,
2379                    ..terms.clone()
2380                },
2381                vec![
2382                    (1e-198, 1e-198, 1e-198, 1e-198),
2383                    (1e-198, 1e-198, 1e-198, 1e-198),
2384                    (1e-198, 1.05e-198, -1e-198, 1e-198),
2385                ],
2386                vec![trade(
2387                    Buy,
2388                    5e200,
2389                    1,
2390                    1e-198,
2391                    Some((2, -200.0 / 3.0 * 1e-200, -2500.0 / 3.0)),
2392                    "A / Margin call",
2393                )],
2394                vec![1000.0, 1000.0, 1000.0, 500.0 / 3.0],
2395                0,
2396            ),
2397            (
2398                "negative price margin call",
2399                hold,
2400                BacktestSettings {
2401                    order_size: OrderSize::Fixed(5.0),
2402                    leverage: 2.0,
2403                    ..terms.clone()
2404                },
2405                vec![
2406                    (100.0, 100.0, 100.0, 100.0),
2407                    (100.0, 100.0, 100.0, 100.0),
2408                    (100.0, 105.0, -100.0, 100.0),
2409                ],
2410                vec![trade(
2411                    Buy,
2412                    5.0,
2413                    1,
2414                    100.0,
2415                    Some((2, -200.0 / 3.0, -2500.0 / 3.0)),
2416                    "A / Margin call",
2417                )],
2418                vec![1000.0, 1000.0, 1000.0, 500.0 / 3.0],
2419                0,
2420            ),
2421            (
2422                "short slippage",
2423                sized_short,
2424                BacktestSettings {
2425                    slippage: Slippage::Ticks {
2426                        ticks: 2,
2427                        tick_size: 0.5,
2428                    },
2429                    ..terms.clone()
2430                },
2431                flat(&[100.0, 100.0, 90.0]),
2432                vec![trade(Sell, 2.0, 1, 99.0, Some((2, 91.0, 16.0)), "A / X")],
2433                vec![1000.0, 1000.0, 998.0, 1016.0],
2434                0,
2435            ),
2436            (
2437                "stop-limit never slips",
2438                stop_limits,
2439                BacktestSettings {
2440                    slippage: Slippage::Fraction(0.1),
2441                    ..terms.clone()
2442                },
2443                vec![(100.0, 100.0, 100.0, 100.0), (100.0, 110.0, 99.0, 108.0)],
2444                vec![trade(Buy, 2.0, 1, 105.0, Some((1, 110.0, 10.0)), "A / X")],
2445                vec![1000.0, 1000.0, 1010.0],
2446                0,
2447            ),
2448            (
2449                "tiny quantity retains order fees",
2450                sized,
2451                BacktestSettings {
2452                    order_size: OrderSize::Fixed(1e-300),
2453                    commission: Commission::PerOrder(1.0),
2454                    ..terms.clone()
2455                },
2456                flat(&[100.0, 100.0, 110.0]),
2457                vec![fee_trade(
2458                    Buy,
2459                    1e-300,
2460                    1,
2461                    100.0,
2462                    Some((2, 110.0, -2.0)),
2463                    2.0,
2464                    "A / X",
2465                )],
2466                vec![1000.0, 1000.0, 999.0, 998.0],
2467                0,
2468            ),
2469            (
2470                "recalculation waits for the next point",
2471                cycle,
2472                BacktestSettings {
2473                    recalculate_after_fills: true,
2474                    ..terms.clone()
2475                },
2476                vec![(100.0, 100.0, 100.0, 100.0), (100.0, 101.0, 98.0, 100.0)],
2477                vec![
2478                    trade(Buy, 2.0, 1, 100.0, Some((1, 101.0, 2.0)), "A / X"),
2479                    trade(Buy, 2.0, 1, 98.0, Some((1, 100.0, 4.0)), "A / X"),
2480                ],
2481                vec![1000.0, 1000.0, 1006.0],
2482                0,
2483            ),
2484            (
2485                "flat bars retain successive fill points",
2486                cycle,
2487                BacktestSettings {
2488                    recalculate_after_fills: true,
2489                    ..terms.clone()
2490                },
2491                flat(&[100.0, 100.0]),
2492                vec![
2493                    trade(Buy, 2.0, 1, 100.0, Some((1, 100.0, 0.0)), "A / X"),
2494                    trade(Buy, 2.0, 1, 100.0, Some((1, 100.0, 0.0)), "A / X"),
2495                ],
2496                vec![1000.0; 3],
2497                0,
2498            ),
2499            (
2500                "recalculate after fills",
2501                recalculate,
2502                BacktestSettings {
2503                    recalculate_after_fills: true,
2504                    ..terms.clone()
2505                },
2506                vec![(100.0, 100.0, 100.0, 100.0), (100.0, 110.0, 99.0, 108.0)],
2507                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 105.0, 10.0)), "A / X")],
2508                vec![1000.0, 1000.0, 1010.0],
2509                0,
2510            ),
2511        ] {
2512            let source: Vec<_> = prices
2513                .into_iter()
2514                .enumerate()
2515                .map(|(i, (o, h, l, c))| Bar::new(hour(i as i64), o, h, l, c, 0.0))
2516                .collect();
2517            let mut engine =
2518                Backtest::new("Terms", "TEST", Timeframe::Hour1, settings.initial_capital);
2519            engine.settings = settings;
2520            let report = engine.run(&mut Script(script), &source, &[]);
2521            let mut asynchronous_strategy = Script(script);
2522            let mut future =
2523                std::pin::pin!(engine.run_async(&mut asynchronous_strategy, &source, &[]));
2524            let mut context = std::task::Context::from_waker(std::task::Waker::noop());
2525            let mut batches = 0;
2526            let asynchronous = loop {
2527                match std::future::Future::poll(future.as_mut(), &mut context) {
2528                    std::task::Poll::Ready(report) => break report,
2529                    std::task::Poll::Pending => batches += 1,
2530                }
2531            };
2532            assert!(batches > 0, "asynchronous fills must yield");
2533            assert_eq!(
2534                serde_json::to_value(&asynchronous).unwrap(),
2535                serde_json::to_value(&report).unwrap()
2536            );
2537
2538            assert_eq!(report.currency, Some("USD".into()), "{name}");
2539            assert_eq!(report.risk_free_rate, 0.02, "{name}");
2540            assert!(
2541                report
2542                    .properties
2543                    .contains(&("Rejected orders".into(), rejected.to_string())),
2544                "{name}"
2545            );
2546            assert_eq!(report.trades.len(), expected.len(), "{name}");
2547            for (mut actual, expected) in report.trades.iter().cloned().zip(expected) {
2548                assert!(
2549                    (actual.quantity - expected.quantity).abs() < 1e-10,
2550                    "{name}"
2551                );
2552                assert!((actual.fees - expected.fees).abs() < 1e-10, "{name}");
2553                actual.quantity = expected.quantity;
2554                actual.fees = expected.fees;
2555                if let (Some(a), Some(e)) = (&mut actual.exit, &expected.exit) {
2556                    assert!((a.price - e.price).abs() < 1e-10, "{name}");
2557                    assert!((a.profit - e.profit).abs() < 1e-10, "{name}");
2558                    a.price = e.price;
2559                    a.profit = e.profit;
2560                }
2561                assert_eq!(actual, expected, "{name}");
2562            }
2563            assert_eq!(report.equity.len(), equity.len(), "{name}");
2564            for ((time, actual), (expected_time, expected)) in report
2565                .equity
2566                .iter()
2567                .zip((-1..source.len() as i64).map(hour).zip(equity))
2568            {
2569                assert_eq!(*time, expected_time, "{name}");
2570                assert!(
2571                    (actual - expected).abs() < 1e-10,
2572                    "{name}: {actual} != {expected}"
2573                );
2574            }
2575        }
2576
2577        struct History {
2578            prepared: usize,
2579            averages: Vec<f64>,
2580            decisions: Vec<usize>,
2581            entry_bar: usize,
2582        }
2583        impl Strategy for History {
2584            fn inputs(&self) -> Vec<(String, String)> {
2585                vec![("Length".into(), "2".into())]
2586            }
2587            fn prepare(&mut self, bars: &[Bar]) {
2588                self.prepared = bars.len();
2589                self.averages = bars
2590                    .windows(2)
2591                    .map(|pair| (pair[0].close + pair[1].close) / 2.0)
2592                    .collect();
2593            }
2594            fn on_bar(&mut self, context: &mut StrategyContext<'_>) {
2595                let index = context.bars().len() - 1;
2596                self.decisions.push(index);
2597                if index <= self.entry_bar {
2598                    if index == 1 {
2599                        assert_eq!(self.averages[0], 104.0, "warm-up bar feeds the study");
2600                    }
2601                    context.entry("A", Buy, 2.0, OrderType::Market);
2602                    context.exit("X", None, Some(110.0), Some(95.0));
2603                }
2604            }
2605        }
2606        let source = vec![
2607            Bar::new(hour(0), 100.0, 100.0, 100.0, 100.0, 0.0),
2608            Bar::new(hour(1), 100.0, 111.0, 94.0, 108.0, 0.0),
2609            Bar::new(hour(2), 108.0, 108.0, 108.0, 108.0, 0.0),
2610        ];
2611        let first = Bar::new(hour(1), 100.0, 111.0, 99.0, 110.0, 0.0);
2612        let second = Bar::new(
2613            hour(1) + TimeDelta::minutes(30),
2614            110.0,
2615            110.0,
2616            94.0,
2617            108.0,
2618            0.0,
2619        );
2620        let mut later_target = trade(Buy, 2.0, 1, 100.0, Some((1, 110.0, 20.0)), "A / X");
2621        later_target.exit.as_mut().unwrap().time += TimeDelta::minutes(30);
2622        // Chart path: 100 -> 94 -> 111 -> 108 stops at 95, losing 2*(100-95)=10.
2623        // Finer path: 100 -> 99 -> 111 reaches the 110 target first, earning 2*10=20.
2624        // A start at 01:00 warms on 00:00, decides at 01:00, and buys at 02:00 for 108.
2625        for (name, lower, start, entry_bar, expected, times, decisions) in [
2626            (
2627                "chart path",
2628                vec![],
2629                None,
2630                0,
2631                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 95.0, -10.0)), "A / X")],
2632                vec![hour(-1), hour(0), hour(1), hour(2)],
2633                vec![0, 1, 2],
2634            ),
2635            (
2636                "finer path",
2637                vec![first.clone(), second.clone()],
2638                None,
2639                0,
2640                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 110.0, 20.0)), "A / X")],
2641                vec![hour(-1), hour(0), hour(1), hour(2)],
2642                vec![0, 1, 2],
2643            ),
2644            (
2645                "only fills use finer timestamps",
2646                vec![
2647                    Bar::new(hour(1), 100.0, 101.0, 99.0, 100.0, 0.0),
2648                    Bar::new(
2649                        hour(1) + TimeDelta::minutes(30),
2650                        100.0,
2651                        111.0,
2652                        99.0,
2653                        108.0,
2654                        0.0,
2655                    ),
2656                ],
2657                None,
2658                0,
2659                vec![later_target],
2660                vec![hour(-1), hour(0), hour(1), hour(2)],
2661                vec![0, 1, 2],
2662            ),
2663            (
2664                "finer bars are ordered",
2665                vec![second.clone(), first.clone()],
2666                None,
2667                0,
2668                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 110.0, 20.0)), "A / X")],
2669                vec![hour(-1), hour(0), hour(1), hour(2)],
2670                vec![0, 1, 2],
2671            ),
2672            (
2673                "uncovered bar falls back",
2674                vec![Bar::new(hour(2), 108.0, 108.0, 108.0, 108.0, 0.0)],
2675                None,
2676                0,
2677                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 95.0, -10.0)), "A / X")],
2678                vec![hour(-1), hour(0), hour(1), hour(2)],
2679                vec![0, 1, 2],
2680            ),
2681            (
2682                "invalid finer bar falls back",
2683                vec![Bar::new(hour(1), 100.0, f64::NAN, 99.0, 100.0, 0.0)],
2684                None,
2685                0,
2686                vec![trade(Buy, 2.0, 1, 100.0, Some((1, 95.0, -10.0)), "A / X")],
2687                vec![hour(-1), hour(0), hour(1), hour(2)],
2688                vec![0, 1, 2],
2689            ),
2690            (
2691                "start warms studies",
2692                vec![],
2693                Some(hour(1)),
2694                1,
2695                vec![trade(Buy, 2.0, 2, 108.0, None, "A")],
2696                vec![hour(1), hour(2), hour(3)],
2697                vec![0, 1, 2],
2698            ),
2699            (
2700                "start between chart bars",
2701                vec![first.clone(), second.clone()],
2702                Some(hour(0) + TimeDelta::minutes(30)),
2703                1,
2704                vec![trade(Buy, 2.0, 2, 108.0, None, "A")],
2705                vec![hour(0) + TimeDelta::minutes(30), hour(2), hour(3)],
2706                vec![0, 1, 2],
2707            ),
2708            (
2709                "start after history",
2710                vec![first, second],
2711                Some(hour(3)),
2712                3,
2713                vec![],
2714                vec![],
2715                vec![0, 1, 2],
2716            ),
2717        ] {
2718            let mut strategy = History {
2719                prepared: 0,
2720                averages: vec![],
2721                decisions: vec![],
2722                entry_bar,
2723            };
2724            let mut engine = Backtest::new("History", "TEST", Timeframe::Hour1, 1000.0);
2725            engine.settings.start_time = start;
2726            let report = engine.run(&mut strategy, &source, &lower);
2727            assert_eq!(strategy.prepared, source.len(), "{name}");
2728            assert_eq!(strategy.decisions, decisions, "{name}");
2729            strategy.decisions.clear();
2730            let mut future = std::pin::pin!(engine.run_async(&mut strategy, &source, &lower));
2731            let mut context = std::task::Context::from_waker(std::task::Waker::noop());
2732            assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending());
2733            let std::task::Poll::Ready(asynchronous) =
2734                std::future::Future::poll(future.as_mut(), &mut context)
2735            else {
2736                panic!("{name}: these chart and finer bars fit in one execution batch");
2737            };
2738            assert_eq!(asynchronous, report, "{name}");
2739            assert_eq!(report.trades, expected, "{name}");
2740            assert_eq!(
2741                report
2742                    .equity
2743                    .iter()
2744                    .map(|(time, _)| *time)
2745                    .collect::<Vec<_>>(),
2746                times,
2747                "{name}"
2748            );
2749            let expected_last = expected
2750                .first()
2751                .and_then(|trade| trade.exit.as_ref())
2752                .map_or(1000.0, |exit| 1000.0 + exit.profit);
2753            if let Some((_, last)) = report.equity.last() {
2754                assert_eq!(*last, expected_last, "{name}");
2755            }
2756            #[cfg(feature = "csv")]
2757            {
2758                let csv = report.summary_csv(&report.performance(), &report.comparison(&source));
2759                for setting in ["Initial capital", "Currency", "Risk-free rate"] {
2760                    assert_eq!(
2761                        csv.lines()
2762                            .filter(|row| row.starts_with(&format!("properties,{setting},")))
2763                            .count(),
2764                        1,
2765                        "{name}: {setting} must appear once"
2766                    );
2767                }
2768            }
2769            assert!(
2770                report
2771                    .properties
2772                    .contains(&("Input: Length".into(), "2".into()))
2773            );
2774            for setting in [
2775                "Order size",
2776                "Pyramiding",
2777                "Commission",
2778                "Slippage",
2779                "Leverage",
2780                "Fill on close",
2781                "Recalculate after fills",
2782                "Start time",
2783                "Lower-interval bars",
2784                "Rejected orders",
2785            ] {
2786                assert!(
2787                    report.properties.iter().any(|(key, _)| key == setting),
2788                    "{name}: missing {setting}"
2789                );
2790            }
2791            if let Some(start) = start {
2792                assert!(report.trades.iter().all(|trade| trade.entry_time >= start));
2793                assert!(report.equity.iter().all(|(time, _)| *time >= start));
2794                assert_eq!(report.equity_curve(), report.equity, "{name}");
2795            }
2796        }
2797
2798        fn stop_then_close(bar: usize, context: &mut StrategyContext<'_>) {
2799            match bar {
2800                0 => context.entry("A", Buy, 2.0, OrderType::Stop(105.0)),
2801                1 => context.exit("X", None, None, None),
2802                _ => {}
2803            }
2804        }
2805        // An on-close exit follows a finer intrabar entry in real time, rather than
2806        // inheriting the chart open's timestamp and becoming an invalid backwards trade.
2807        for (lower, entry_time, exit_time) in [
2808            (vec![], hour(1), hour(1)),
2809            (
2810                vec![
2811                    Bar::new(hour(1), 100.0, 101.0, 99.0, 100.0, 0.0),
2812                    Bar::new(
2813                        hour(1) + TimeDelta::minutes(30),
2814                        100.0,
2815                        111.0,
2816                        99.0,
2817                        108.0,
2818                        0.0,
2819                    ),
2820                ],
2821                hour(1) + TimeDelta::minutes(30),
2822                hour(2),
2823            ),
2824        ] {
2825            let mut engine = Backtest::new("On close", "TEST", Timeframe::Hour1, 1000.0);
2826            engine.settings.fill_on_close = true;
2827            let report = engine.run(&mut Script(stop_then_close), &source, &lower);
2828            assert_eq!(report.performance().closed_trades, 1);
2829            assert_eq!(report.trades[0].entry_time, entry_time);
2830            let exit = report.trades[0].exit.as_ref().unwrap();
2831            assert_eq!(exit.time, exit_time);
2832            assert_eq!(exit.profit, 6.0);
2833        }
2834
2835        // The last execution bar has no next open. Its provisional mark is one clock tick
2836        // after its last source time, so the starting capital and every close stay ordered.
2837        for (count, lower, times) in [
2838            (
2839                1,
2840                vec![],
2841                vec![hour(0), hour(0) + TimeDelta::nanoseconds(1)],
2842            ),
2843            (
2844                2,
2845                vec![Bar::new(
2846                    hour(1) + TimeDelta::minutes(30),
2847                    100.0,
2848                    100.0,
2849                    100.0,
2850                    100.0,
2851                    0.0,
2852                )],
2853                vec![
2854                    hour(0),
2855                    hour(1),
2856                    hour(1) + TimeDelta::minutes(30) + TimeDelta::nanoseconds(1),
2857                ],
2858            ),
2859        ] {
2860            let source: Vec<_> = (0..count)
2861                .map(|i| Bar::new(hour(i), 100.0, 100.0, 100.0, 100.0, 0.0))
2862                .collect();
2863            let mut engine = Backtest::new(
2864                "Execution",
2865                "TEST",
2866                Timeframe::Ticks(std::num::NonZeroU64::new(1).unwrap()),
2867                1000.0,
2868            );
2869            engine.settings = BacktestSettings {
2870                start_time: Some(hour(0)),
2871                fill_on_close: true,
2872                commission: Commission::PerOrder(1.0),
2873                ..terms.clone()
2874            };
2875            let report = engine.run(&mut Script(hold), &source, &lower);
2876            assert_eq!(
2877                report
2878                    .equity
2879                    .iter()
2880                    .map(|(time, _)| *time)
2881                    .collect::<Vec<_>>(),
2882                times
2883            );
2884            assert_eq!(report.equity_curve(), report.equity);
2885            assert_eq!(report.performance().max_drawdown, Some(1.0));
2886            assert_eq!(report.equity.last().unwrap().1, 999.0);
2887            assert_eq!(report.trades[0].fees, 1.0);
2888        }
2889    }
2890}