Skip to main content

kestrel_chartkit/
execution.rs

1//! Provider-neutral order/fill simulator: market/limit/stop/stop-limit/trailing orders, partial
2//! fills bounded by a per-bar participation cap, pyramiding (multiple same-direction fills
3//! accumulating one position), fees/spread/slippage, and explicit position state.
4//!
5//! Intrabar fill logic is a documented approximation, not a claim of perfect intrabar path
6//! replay: a bar's open/high/low/close order is assumed (configurable), and whichever of
7//! high/low is reached "first" under that assumption determines which side of a bar a resting
8//! order fills against. Real intrabar order is unknowable from OHLC alone.
9
10use crate::model::Bar;
11use std::collections::BTreeMap;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OrderSide {
15    Buy,
16    Sell,
17}
18
19impl OrderSide {
20    fn sign(self) -> f64 {
21        match self {
22            OrderSide::Buy => 1.0,
23            OrderSide::Sell => -1.0,
24        }
25    }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub enum OrderKind {
30    Market,
31    Limit {
32        price: f64,
33    },
34    Stop {
35        trigger: f64,
36    },
37    StopLimit {
38        trigger: f64,
39        limit: f64,
40    },
41    /// Trailing stop: `trail_amount` is the fixed price distance kept behind the best price seen
42    /// since the order was submitted.
43    Trailing {
44        trail_amount: f64,
45    },
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum OrderStatus {
50    Pending,
51    PartiallyFilled,
52    Filled,
53    Cancelled,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct Order {
58    pub id: u64,
59    pub side: OrderSide,
60    pub kind: OrderKind,
61    pub quantity: f64,
62    pub filled_quantity: f64,
63    pub status: OrderStatus,
64    /// For `OrderKind::Trailing`: the current computed stop level, updated every bar.
65    pub trailing_stop_price: Option<f64>,
66    /// For `OrderKind::StopLimit`: `true` once the trigger has been crossed and the order behaves
67    /// as a resting limit order at `limit`.
68    pub stop_triggered: bool,
69    /// Set for orders submitted through [`submit_bracket`]: identifies this order's bracket group
70    /// and its role within it. `None` for a standalone order submitted through
71    /// [`FillSimulator::submit`].
72    pub bracket: Option<BracketLink>,
73}
74
75/// Identifies an order as part of a bracket (entry + stop-loss + take-profit) submitted via
76/// [`submit_bracket`].
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct BracketLink {
79    /// Order ID of this bracket's entry order (the entry's own `BracketLink::entry_id` equals its
80    /// own `id`).
81    pub entry_id: u64,
82    /// This order's role within the bracket.
83    pub role: BracketRole,
84}
85
86/// An order's role within a bracket.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum BracketRole {
89    /// The order that opens the position.
90    Entry,
91    /// The stop-loss exit.
92    StopLoss,
93    /// The take-profit exit.
94    TakeProfit,
95}
96
97/// How a bracket's stop-loss and take-profit are ordered when a single bar's OHLC range touches
98/// both. Real intrabar order is unknowable from OHLC alone; this makes the assumption explicit and
99/// deterministic instead of leaving it to fill-collection order.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub enum IntrabarFillPolicy {
102    /// The stop-loss is assumed to be touched first, i.e. the conservative (worse-for-the-
103    /// position) outcome is realized. Default.
104    #[default]
105    StopFirst,
106    /// The take-profit is assumed to be touched first.
107    TargetFirst,
108}
109
110/// Per-bracket bookkeeping: how much of the entry has filled so far, and how much of that has
111/// already been closed by one of its exits. `entry_filled - exit_closed` is the exit capacity
112/// available to the stop-loss/take-profit this bracket's exits may still consume.
113#[derive(Debug, Clone, Copy, PartialEq, Default)]
114struct BracketState {
115    entry_filled: f64,
116    exit_closed: f64,
117    /// `true` once the entry can no longer contribute more fills (fully `Filled` or
118    /// `Cancelled`), so a subsequently-exhausted exit capacity is permanent rather than just
119    /// "not yet replenished by a later partial entry fill".
120    entry_done: bool,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct Fill {
125    pub order_id: u64,
126    pub side: OrderSide,
127    pub price: f64,
128    pub quantity: f64,
129    pub fee: f64,
130    pub timestamp: i64,
131}
132
133/// Trading costs applied to every fill.
134#[derive(Debug, Clone, Copy, PartialEq, Default)]
135pub struct ExecutionCosts {
136    /// Fraction of notional charged as a fee per fill (e.g. `0.001` = 10 bps).
137    pub fee_pct: f64,
138    /// Fixed price spread applied against the order side (buys fill `spread/2` higher, sells
139    /// `spread/2` lower).
140    pub spread: f64,
141    /// Additional adverse slippage as a fraction of price, applied the same direction as spread.
142    pub slippage_pct: f64,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Default)]
146pub struct Position {
147    /// Positive = long, negative = short, `0.0` = flat.
148    pub quantity: f64,
149    pub avg_entry_price: f64,
150    pub realized_pnl: f64,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Default)]
154pub struct FillSimulatorConfig {
155    pub costs: ExecutionCosts,
156    /// Caps how much of a pending order's remaining quantity can fill in one bar, as a fraction
157    /// of that bar's volume (`None` = no cap, fill fully when price conditions are met). Models
158    /// participation-rate-limited partial fills.
159    pub max_fill_ratio_of_volume: Option<f64>,
160    /// Maximum number of same-direction fills accumulated into one position (pyramiding cap).
161    /// `None` = unlimited.
162    pub max_pyramid_entries: Option<u32>,
163    /// Same-bar tie-break when a bracket's stop-loss and take-profit are both touched by the same
164    /// OHLC bar. See [`IntrabarFillPolicy`].
165    pub bracket_intrabar_policy: IntrabarFillPolicy,
166}
167
168pub struct FillSimulator {
169    config: FillSimulatorConfig,
170    orders: Vec<Order>,
171    next_order_id: u64,
172    position: Position,
173    pyramid_entries: u32,
174    fills: Vec<Fill>,
175    /// Keyed by entry order ID. Tracks how much of each bracket's entry has filled and how much
176    /// has already been closed by an exit, so exits stay bounded by the position they are
177    /// actually protecting (finding 02).
178    bracket_state: BTreeMap<u64, BracketState>,
179}
180
181impl FillSimulator {
182    pub fn new(config: FillSimulatorConfig) -> Self {
183        Self {
184            config,
185            orders: Vec::new(),
186            next_order_id: 1,
187            position: Position::default(),
188            pyramid_entries: 0,
189            fills: Vec::new(),
190            bracket_state: BTreeMap::new(),
191        }
192    }
193
194    pub fn position(&self) -> Position {
195        self.position
196    }
197
198    pub fn fills(&self) -> &[Fill] {
199        &self.fills
200    }
201
202    pub fn open_orders(&self) -> impl Iterator<Item = &Order> {
203        self.orders.iter().filter(|o| {
204            matches!(
205                o.status,
206                OrderStatus::Pending | OrderStatus::PartiallyFilled
207            )
208        })
209    }
210
211    /// Submits a new order, returning its ID. Rejected (returns `None`) if this would exceed
212    /// `max_pyramid_entries` same-direction accumulations.
213    pub fn submit(&mut self, side: OrderSide, kind: OrderKind, quantity: f64) -> Option<u64> {
214        self.submit_internal(side, kind, quantity, None)
215    }
216
217    fn submit_internal(
218        &mut self,
219        side: OrderSide,
220        kind: OrderKind,
221        quantity: f64,
222        bracket: Option<BracketLink>,
223    ) -> Option<u64> {
224        if quantity <= 0.0 {
225            return None;
226        }
227        let would_pyramid = self.position.quantity != 0.0
228            && self.position.quantity.signum() == side.sign()
229            && self.pyramid_entries > 0;
230        if would_pyramid {
231            if let Some(max) = self.config.max_pyramid_entries {
232                if self.pyramid_entries >= max {
233                    return None;
234                }
235            }
236        }
237
238        let id = self.next_order_id;
239        self.next_order_id += 1;
240        self.orders.push(Order {
241            id,
242            side,
243            kind,
244            quantity,
245            filled_quantity: 0.0,
246            status: OrderStatus::Pending,
247            trailing_stop_price: None,
248            stop_triggered: false,
249            bracket,
250        });
251        Some(id)
252    }
253
254    pub fn cancel(&mut self, order_id: u64) -> bool {
255        if let Some(order) = self.orders.iter_mut().find(|o| o.id == order_id) {
256            if matches!(
257                order.status,
258                OrderStatus::Pending | OrderStatus::PartiallyFilled
259            ) {
260                order.status = OrderStatus::Cancelled;
261                if let Some(BracketLink {
262                    entry_id,
263                    role: BracketRole::Entry,
264                }) = order.bracket
265                {
266                    // The entry can no longer contribute more fills; any resting exit capacity
267                    // it already granted is now permanent, not "pending more".
268                    self.bracket_state.entry(entry_id).or_default().entry_done = true;
269                }
270                return true;
271            }
272        }
273        false
274    }
275
276    /// Processes one bar against all resting orders: updates trailing stops, checks fill
277    /// conditions, applies costs, and updates position state. Returns the fills produced this
278    /// bar.
279    ///
280    /// Bracket orders (submitted via [`submit_bracket`]) are evaluated in two passes: standalone
281    /// orders and bracket entries first, then bracket exits. A bracket's stop-loss/take-profit
282    /// only become active once (and only for as much quantity as) the entry has actually filled,
283    /// and the two exits share one OCO capacity budget (`entry_filled - exit_closed`) so that
284    /// whichever fills first — per [`IntrabarFillPolicy`] when a single bar touches both —
285    /// immediately caps the other within the same bar. This prevents a bracket's exits from
286    /// filling before its entry, or from jointly closing more than the position they protect.
287    pub fn on_bar(&mut self, bar: &Bar, timestamp: i64) -> Vec<Fill> {
288        let mut bar_fills = Vec::new();
289        let max_fill_qty = self
290            .config
291            .max_fill_ratio_of_volume
292            .map(|r| (r * bar.volume).max(0.0));
293
294        let primary_ids: Vec<u64> = self
295            .orders
296            .iter()
297            .filter(|o| {
298                matches!(
299                    o.status,
300                    OrderStatus::Pending | OrderStatus::PartiallyFilled
301                )
302            })
303            .filter(|o| {
304                !matches!(
305                    o.bracket,
306                    Some(BracketLink {
307                        role: BracketRole::StopLoss | BracketRole::TakeProfit,
308                        ..
309                    })
310                )
311            })
312            .map(|o| o.id)
313            .collect();
314        for id in primary_ids {
315            self.attempt_fill(id, bar, timestamp, max_fill_qty, None, &mut bar_fills);
316        }
317
318        let mut brackets: BTreeMap<u64, (Option<u64>, Option<u64>)> = BTreeMap::new();
319        for order in self.orders.iter().filter(|o| {
320            matches!(
321                o.status,
322                OrderStatus::Pending | OrderStatus::PartiallyFilled
323            )
324        }) {
325            if let Some(BracketLink { entry_id, role }) = order.bracket {
326                let slot = brackets.entry(entry_id).or_default();
327                match role {
328                    BracketRole::StopLoss => slot.0 = Some(order.id),
329                    BracketRole::TakeProfit => slot.1 = Some(order.id),
330                    BracketRole::Entry => {}
331                }
332            }
333        }
334        for (entry_id, (stop_id, target_id)) in brackets {
335            let ordered = match self.config.bracket_intrabar_policy {
336                IntrabarFillPolicy::StopFirst => [stop_id, target_id],
337                IntrabarFillPolicy::TargetFirst => [target_id, stop_id],
338            };
339            for id in ordered.into_iter().flatten() {
340                let capacity = self
341                    .bracket_state
342                    .get(&entry_id)
343                    .map(|s| s.entry_filled - s.exit_closed)
344                    .unwrap_or(0.0);
345                if capacity <= 0.0 {
346                    continue;
347                }
348                self.attempt_fill(
349                    id,
350                    bar,
351                    timestamp,
352                    max_fill_qty,
353                    Some(capacity),
354                    &mut bar_fills,
355                );
356            }
357        }
358
359        for fill in &bar_fills {
360            self.apply_fill(fill);
361        }
362        self.fills.extend(bar_fills.iter().copied());
363
364        // Once a bracket's exit capacity is exhausted (the position it protects is fully
365        // closed), cancel the untouched sibling instead of leaving a dead resting order that can
366        // never fill again.
367        self.cancel_exhausted_bracket_exits();
368
369        // Terminal orders (Filled/Cancelled) no longer participate in fill checks; their history
370        // already lives in `self.fills`, so drop them here rather than rescanning them forever.
371        self.orders
372            .retain(|o| !matches!(o.status, OrderStatus::Filled | OrderStatus::Cancelled));
373
374        bar_fills
375    }
376
377    /// Evaluates a single order against `bar` and, if its fill conditions are met, records a fill
378    /// (capped by `max_fill_qty` and, for bracket exits, by `capacity_cap`) and updates the
379    /// order's own state. Bracket accounting (`bracket_state`) is updated here too, so a sibling
380    /// exit evaluated later in the same bar sees an up-to-date capacity.
381    fn attempt_fill(
382        &mut self,
383        order_id: u64,
384        bar: &Bar,
385        timestamp: i64,
386        max_fill_qty: Option<f64>,
387        capacity_cap: Option<f64>,
388        bar_fills: &mut Vec<Fill>,
389    ) {
390        let Some(idx) = self.orders.iter().position(|o| o.id == order_id) else {
391            return;
392        };
393
394        {
395            let order = &mut self.orders[idx];
396            if !matches!(
397                order.status,
398                OrderStatus::Pending | OrderStatus::PartiallyFilled
399            ) {
400                return;
401            }
402
403            if let OrderKind::Trailing { trail_amount } = order.kind {
404                // Sell (exits a long): stop trails below the high, ratcheting up only.
405                // Buy (exits/covers a short): stop trails above the low, ratcheting down only.
406                let candidate = match order.side {
407                    OrderSide::Sell => bar.high - trail_amount,
408                    OrderSide::Buy => bar.low + trail_amount,
409                };
410                order.trailing_stop_price = Some(match (order.trailing_stop_price, order.side) {
411                    (Some(prev), OrderSide::Sell) => prev.max(candidate),
412                    (Some(prev), OrderSide::Buy) => prev.min(candidate),
413                    (None, _) => candidate,
414                });
415            }
416
417            if let OrderKind::StopLimit { trigger, .. } = order.kind {
418                if !order.stop_triggered {
419                    let crossed = match order.side {
420                        OrderSide::Buy => bar.high >= trigger,
421                        OrderSide::Sell => bar.low <= trigger,
422                    };
423                    if crossed {
424                        order.stop_triggered = true;
425                    }
426                }
427            }
428        }
429
430        let (oid, side, bracket, executed_price, fill_qty, fee) = {
431            let order = &self.orders[idx];
432            let Some(fill_price) = fill_price_for(order, bar) else {
433                return;
434            };
435
436            let remaining = order.quantity - order.filled_quantity;
437            let mut fill_qty = max_fill_qty
438                .map(|cap| remaining.min(cap))
439                .unwrap_or(remaining);
440            if let Some(cap) = capacity_cap {
441                fill_qty = fill_qty.min(cap.max(0.0));
442            }
443            if fill_qty <= 0.0 {
444                return;
445            }
446
447            let costs = self.config.costs;
448            let side_sign = order.side.sign();
449            let executed_price = fill_price
450                * (1.0
451                    + side_sign * (costs.spread / fill_price.max(1e-9) / 2.0 + costs.slippage_pct));
452            let fee = executed_price * fill_qty * costs.fee_pct;
453            (
454                order.id,
455                order.side,
456                order.bracket,
457                executed_price,
458                fill_qty,
459                fee,
460            )
461        };
462
463        let new_status = {
464            let order = &mut self.orders[idx];
465            order.filled_quantity += fill_qty;
466            order.status = if order.filled_quantity >= order.quantity - 1e-9 {
467                OrderStatus::Filled
468            } else {
469                OrderStatus::PartiallyFilled
470            };
471            order.status
472        };
473
474        bar_fills.push(Fill {
475            order_id: oid,
476            side,
477            price: executed_price,
478            quantity: fill_qty,
479            fee,
480            timestamp,
481        });
482
483        if let Some(link) = bracket {
484            let state = self.bracket_state.entry(link.entry_id).or_default();
485            match link.role {
486                BracketRole::Entry => {
487                    state.entry_filled += fill_qty;
488                    if new_status == OrderStatus::Filled {
489                        // Fully filled: no more capacity will ever be granted to this bracket's
490                        // exits, so an exhausted capacity from here on is permanent.
491                        state.entry_done = true;
492                    }
493                }
494                BracketRole::StopLoss | BracketRole::TakeProfit => state.exit_closed += fill_qty,
495            }
496        }
497    }
498
499    /// Cancels a bracket's still-resting exit(s) once the entry can no longer contribute more
500    /// fills (fully filled or cancelled) *and* the capacity already granted has been fully
501    /// consumed, so a stale, permanently-unfillable order does not linger. A capacity of zero
502    /// while the entry is still `Pending`/`PartiallyFilled` (more fills may still arrive) is left
503    /// alone.
504    fn cancel_exhausted_bracket_exits(&mut self) {
505        let exhausted: Vec<u64> = self
506            .bracket_state
507            .iter()
508            .filter(|(_, s)| s.entry_done && s.entry_filled - s.exit_closed <= 1e-9)
509            .map(|(entry_id, _)| *entry_id)
510            .collect();
511        if exhausted.is_empty() {
512            return;
513        }
514        for order in &mut self.orders {
515            if let Some(BracketLink { entry_id, role }) = order.bracket {
516                if exhausted.contains(&entry_id)
517                    && matches!(role, BracketRole::StopLoss | BracketRole::TakeProfit)
518                    && matches!(
519                        order.status,
520                        OrderStatus::Pending | OrderStatus::PartiallyFilled
521                    )
522                {
523                    order.status = OrderStatus::Cancelled;
524                }
525            }
526        }
527    }
528
529    fn apply_fill(&mut self, fill: &Fill) {
530        let signed_qty = fill.quantity * fill.side.sign();
531        let prev_qty = self.position.quantity;
532        let new_qty = prev_qty + signed_qty;
533
534        if prev_qty == 0.0 || prev_qty.signum() == signed_qty.signum() {
535            // Opening or adding to a position (pyramiding): weighted-average entry price.
536            let total_cost =
537                self.position.avg_entry_price * prev_qty.abs() + fill.price * fill.quantity;
538            self.position.avg_entry_price = if new_qty.abs() > 1e-12 {
539                total_cost / new_qty.abs()
540            } else {
541                0.0
542            };
543            if prev_qty == 0.0 {
544                self.pyramid_entries = 1;
545            } else {
546                self.pyramid_entries += 1;
547            }
548        } else {
549            // Reducing, closing, or flipping.
550            let closing_qty = fill.quantity.min(prev_qty.abs());
551            let pnl_per_unit = (fill.price - self.position.avg_entry_price) * prev_qty.signum();
552            self.position.realized_pnl += pnl_per_unit * closing_qty;
553
554            if fill.quantity > prev_qty.abs() {
555                // Flip: the excess opens a new position in the opposite direction.
556                self.position.avg_entry_price = fill.price;
557                self.pyramid_entries = 1;
558            } else if new_qty.abs() < 1e-12 {
559                self.position.avg_entry_price = 0.0;
560                self.pyramid_entries = 0;
561            }
562        }
563
564        self.position.realized_pnl -= fill.fee;
565        self.position.quantity = new_qty;
566    }
567}
568
569fn fill_price_for(order: &Order, bar: &Bar) -> Option<f64> {
570    match order.kind {
571        OrderKind::Market => Some(bar.open),
572        OrderKind::Limit { price } => match order.side {
573            OrderSide::Buy if bar.low <= price => Some(price.min(bar.open)),
574            OrderSide::Sell if bar.high >= price => Some(price.max(bar.open)),
575            _ => None,
576        },
577        OrderKind::Stop { trigger } => match order.side {
578            OrderSide::Buy if bar.high >= trigger => Some(trigger.max(bar.open)),
579            OrderSide::Sell if bar.low <= trigger => Some(trigger.min(bar.open)),
580            _ => None,
581        },
582        OrderKind::StopLimit { limit, .. } => {
583            // `order.stop_triggered` is updated (and persisted across bars) by the caller before
584            // this is invoked, so a trigger crossed on an earlier bar still counts here even if
585            // price has since retreated back through the trigger level.
586            if !order.stop_triggered {
587                return None;
588            }
589            match order.side {
590                OrderSide::Buy if bar.low <= limit => Some(limit),
591                OrderSide::Sell if bar.high >= limit => Some(limit),
592                _ => None,
593            }
594        }
595        OrderKind::Trailing { .. } => {
596            let stop = order.trailing_stop_price?;
597            match order.side {
598                OrderSide::Buy if bar.high >= stop => Some(stop.max(bar.open)),
599                OrderSide::Sell if bar.low <= stop => Some(stop.min(bar.open)),
600                _ => None,
601            }
602        }
603    }
604}
605
606/// Submits a bracket: an entry order plus stop-loss and take-profit exits linked to it as one OCO
607/// ("one cancels other") group. The exits are inactive until the entry actually fills, become
608/// active for at most the entry's filled-but-not-yet-closed quantity (so a partial entry fill
609/// cannot be over-closed), and share one capacity budget so that whichever fills first — per
610/// [`FillSimulatorConfig::bracket_intrabar_policy`] when a single bar touches both — immediately
611/// caps the other within the same bar. Callers still get the three order IDs back and may cancel
612/// them individually (e.g. to tear down a bracket whose entry never filled).
613pub fn submit_bracket(
614    sim: &mut FillSimulator,
615    side: OrderSide,
616    quantity: f64,
617    entry: OrderKind,
618    stop_loss_trigger: f64,
619    take_profit_price: f64,
620) -> Option<(u64, u64, u64)> {
621    let exit_side = match side {
622        OrderSide::Buy => OrderSide::Sell,
623        OrderSide::Sell => OrderSide::Buy,
624    };
625    // `next_order_id` is only consumed on a successful submission (see `submit_internal`), so it
626    // reliably predicts the entry's own ID for its self-referencing `BracketLink`.
627    let entry_id = sim.next_order_id;
628    let confirmed_entry_id = sim.submit_internal(
629        side,
630        entry,
631        quantity,
632        Some(BracketLink {
633            entry_id,
634            role: BracketRole::Entry,
635        }),
636    )?;
637    debug_assert_eq!(entry_id, confirmed_entry_id);
638
639    let stop_id = sim.submit_internal(
640        exit_side,
641        OrderKind::Stop {
642            trigger: stop_loss_trigger,
643        },
644        quantity,
645        Some(BracketLink {
646            entry_id,
647            role: BracketRole::StopLoss,
648        }),
649    )?;
650    let target_id = sim.submit_internal(
651        exit_side,
652        OrderKind::Limit {
653            price: take_profit_price,
654        },
655        quantity,
656        Some(BracketLink {
657            entry_id,
658            role: BracketRole::TakeProfit,
659        }),
660    )?;
661    Some((entry_id, stop_id, target_id))
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    fn bar(o: f64, h: f64, l: f64, c: f64, v: f64) -> Bar {
669        Bar::new(0, o, h, l, c, v)
670    }
671
672    #[test]
673    fn test_market_order_fills_at_open() {
674        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
675        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
676        let fills = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
677        assert_eq!(fills.len(), 1);
678        assert_eq!(fills[0].price, 100.0);
679        assert_eq!(sim.position().quantity, 10.0);
680        assert_eq!(sim.position().avg_entry_price, 100.0);
681    }
682
683    #[test]
684    fn test_limit_order_only_fills_when_touched() {
685        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
686        sim.submit(OrderSide::Buy, OrderKind::Limit { price: 95.0 }, 5.0);
687
688        let no_touch = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
689        assert!(no_touch.is_empty());
690
691        let touched = sim.on_bar(&bar(98.0, 99.0, 94.0, 96.0, 1000.0), 60);
692        assert_eq!(touched.len(), 1);
693        assert!(touched[0].price <= 95.0 + 1e-9);
694    }
695
696    #[test]
697    fn test_stop_order_fills_on_trigger() {
698        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
699        sim.submit(OrderSide::Sell, OrderKind::Stop { trigger: 95.0 }, 5.0);
700        let fills = sim.on_bar(&bar(98.0, 99.0, 93.0, 94.0, 1000.0), 0);
701        assert_eq!(fills.len(), 1);
702    }
703
704    #[test]
705    fn test_stop_limit_trigger_persists_across_bars_after_price_retreats() {
706        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
707        sim.submit(
708            OrderSide::Buy,
709            OrderKind::StopLimit {
710                trigger: 100.0,
711                limit: 99.0,
712            },
713            5.0,
714        );
715
716        // Bar 1: trigger crossed (high >= 100), but the limit (99) is not reached this bar
717        // (low stays at 99.5, above the 99.0 limit).
718        let first = sim.on_bar(&bar(100.0, 101.0, 99.5, 100.5, 1000.0), 0);
719        assert!(first.is_empty());
720
721        // Bar 2: price retreats below the trigger but not yet down to the limit -- a naive
722        // re-check would see high(99.4) < trigger(100) and wrongly conclude "not triggered", but
723        // the order must stay armed since it already triggered on bar 1. No fill yet since
724        // low(99.1) is still above the limit(99.0).
725        let second = sim.on_bar(&bar(99.2, 99.4, 99.1, 99.3, 1000.0), 60);
726        assert!(second.is_empty());
727
728        // Bar 3: trades down into the limit price (99); must fill using the persisted trigger.
729        let third = sim.on_bar(&bar(99.5, 100.0, 98.5, 99.0, 1000.0), 120);
730        assert_eq!(third.len(), 1);
731        assert_eq!(third[0].price, 99.0);
732    }
733
734    #[test]
735    fn test_partial_fill_capped_by_volume_ratio() {
736        let mut sim = FillSimulator::new(FillSimulatorConfig {
737            max_fill_ratio_of_volume: Some(0.1),
738            ..Default::default()
739        });
740        let id = sim
741            .submit(OrderSide::Buy, OrderKind::Market, 100.0)
742            .unwrap();
743
744        let first = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 500.0), 0);
745        assert_eq!(first[0].quantity, 50.0); // 10% of 500 volume
746        assert_eq!(
747            sim.open_orders().find(|o| o.id == id).unwrap().status,
748            OrderStatus::PartiallyFilled
749        );
750
751        let second = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 500.0), 60);
752        assert_eq!(second[0].quantity, 50.0);
753        assert!(sim.open_orders().find(|o| o.id == id).is_none());
754        assert_eq!(sim.position().quantity, 100.0);
755    }
756
757    #[test]
758    fn test_pyramiding_accumulates_weighted_average_entry() {
759        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
760        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
761        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
762        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
763        sim.on_bar(&bar(110.0, 111.0, 109.0, 110.5, 1000.0), 60);
764
765        assert_eq!(sim.position().quantity, 20.0);
766        assert!((sim.position().avg_entry_price - 105.0).abs() < 1e-9);
767    }
768
769    #[test]
770    fn test_pyramid_cap_rejects_beyond_limit() {
771        let mut sim = FillSimulator::new(FillSimulatorConfig {
772            max_pyramid_entries: Some(1),
773            ..Default::default()
774        });
775        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
776        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
777
778        let rejected = sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
779        assert!(rejected.is_none());
780    }
781
782    #[test]
783    fn test_opposite_fill_realizes_pnl_and_reduces_position() {
784        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
785        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
786        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
787
788        sim.submit(OrderSide::Sell, OrderKind::Market, 10.0);
789        sim.on_bar(&bar(110.0, 111.0, 109.0, 110.5, 1000.0), 60);
790
791        assert_eq!(sim.position().quantity, 0.0);
792        assert!((sim.position().realized_pnl - 100.0).abs() < 1e-9); // 10 units * $10 gain
793    }
794
795    #[test]
796    fn test_fees_and_spread_reduce_pnl() {
797        let mut sim = FillSimulator::new(FillSimulatorConfig {
798            costs: ExecutionCosts {
799                fee_pct: 0.01,
800                spread: 0.0,
801                slippage_pct: 0.0,
802            },
803            ..Default::default()
804        });
805        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
806        let fills = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
807        assert!(fills[0].fee > 0.0);
808        assert!(
809            sim.position().realized_pnl < 0.0,
810            "fees alone must show as negative realized PnL"
811        );
812    }
813
814    #[test]
815    fn test_trailing_stop_tightens_and_fills() {
816        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
817        // Long position being protected by a trailing sell-stop trailing 2.0 below the high.
818        sim.submit(
819            OrderSide::Sell,
820            OrderKind::Trailing { trail_amount: 2.0 },
821            10.0,
822        );
823
824        // Each bar's own range stays under trail_amount=2.0, so neither bar's low ever reaches
825        // that same bar's freshly computed stop -- isolates "does the stop ratchet and hold"
826        // from "does a bar's own range trigger its own stop".
827        let first = sim.on_bar(&bar(100.0, 105.0, 104.0, 104.5, 1000.0), 0); // stop -> 105-2=103
828        assert!(first.is_empty());
829        let second = sim.on_bar(&bar(104.0, 108.0, 107.0, 107.5, 1000.0), 60); // stop -> max(103,106)=106
830        assert!(second.is_empty());
831
832        // Pulls back to 104: this bar's own high-2=105.5 would suggest a *lower* stop, but the
833        // ratchet must hold at 106 from the prior bar -- and 104 <= 106 triggers the fill.
834        let third = sim.on_bar(&bar(107.0, 107.5, 104.0, 105.0, 1000.0), 120);
835        assert_eq!(third.len(), 1);
836        assert!((third[0].price - 106.0).abs() < 1e-9);
837    }
838
839    #[test]
840    fn test_cancel_prevents_future_fills() {
841        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
842        let id = sim
843            .submit(OrderSide::Buy, OrderKind::Limit { price: 50.0 }, 5.0)
844            .unwrap();
845        assert!(sim.cancel(id));
846        let fills = sim.on_bar(&bar(48.0, 49.0, 45.0, 46.0, 1000.0), 0);
847        assert!(fills.is_empty());
848    }
849
850    #[test]
851    fn test_submit_bracket_creates_entry_and_two_exits() {
852        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
853        let (entry, stop, target) = submit_bracket(
854            &mut sim,
855            OrderSide::Buy,
856            10.0,
857            OrderKind::Market,
858            95.0,
859            110.0,
860        )
861        .unwrap();
862        assert_ne!(entry, stop);
863        assert_ne!(stop, target);
864    }
865
866    /// Finding 02, scenario A: an exit must never fill before its own entry has filled, even if
867    /// the exit's price condition is independently met on the very first bar after submission.
868    #[test]
869    fn test_bracket_exit_cannot_fill_before_entry() {
870        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
871        let (entry_id, stop_id, target_id) = submit_bracket(
872            &mut sim,
873            OrderSide::Buy,
874            10.0,
875            OrderKind::Limit { price: 90.0 },
876            80.0,
877            110.0,
878        )
879        .unwrap();
880
881        // Entry (buy limit 90) is not touched (low 119 > 90), but the target (sell limit 110) is
882        // independently marketable against this bar (high 121 >= 110).
883        let fills = sim.on_bar(&bar(120.0, 121.0, 119.0, 120.0, 1000.0), 0);
884        assert!(
885            fills.is_empty(),
886            "target must not fill before its entry: {fills:?}"
887        );
888        assert_eq!(sim.position().quantity, 0.0);
889        assert_eq!(
890            sim.open_orders().find(|o| o.id == entry_id).unwrap().status,
891            OrderStatus::Pending
892        );
893        assert_eq!(
894            sim.open_orders().find(|o| o.id == stop_id).unwrap().status,
895            OrderStatus::Pending
896        );
897        assert_eq!(
898            sim.open_orders()
899                .find(|o| o.id == target_id)
900                .unwrap()
901                .status,
902            OrderStatus::Pending
903        );
904    }
905
906    /// Finding 02, scenario B: a single bar that touches both stop and target after the entry
907    /// fills must produce exactly one exit fill (per the configured intrabar policy), leaving the
908    /// position flat and cancelling the untouched sibling rather than filling both.
909    #[test]
910    fn test_bracket_same_bar_stop_and_target_only_stop_fires_under_default_policy() {
911        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
912        let (_entry_id, stop_id, target_id) = submit_bracket(
913            &mut sim,
914            OrderSide::Buy,
915            10.0,
916            OrderKind::Market,
917            95.0,
918            110.0,
919        )
920        .unwrap();
921
922        let fills = sim.on_bar(&bar(100.0, 112.0, 94.0, 105.0, 1000.0), 0);
923        assert_eq!(
924            fills.len(),
925            2,
926            "expected entry fill + exactly one exit fill: {fills:?}"
927        );
928        assert_eq!(fills[1].order_id, stop_id, "default policy is stop-first");
929        assert_eq!(sim.position().quantity, 0.0);
930        assert!(
931            sim.open_orders().find(|o| o.id == target_id).is_none(),
932            "untouched sibling must be cancelled, not left resting"
933        );
934    }
935
936    /// Same same-bar collision as above, but under `IntrabarFillPolicy::TargetFirst`: the target
937    /// wins instead, still exactly one exit fill.
938    #[test]
939    fn test_bracket_same_bar_stop_and_target_target_first_policy() {
940        let mut sim = FillSimulator::new(FillSimulatorConfig {
941            bracket_intrabar_policy: IntrabarFillPolicy::TargetFirst,
942            ..Default::default()
943        });
944        let (_entry_id, stop_id, target_id) = submit_bracket(
945            &mut sim,
946            OrderSide::Buy,
947            10.0,
948            OrderKind::Market,
949            95.0,
950            110.0,
951        )
952        .unwrap();
953
954        let fills = sim.on_bar(&bar(100.0, 112.0, 94.0, 105.0, 1000.0), 0);
955        assert_eq!(fills.len(), 2);
956        assert_eq!(fills[1].order_id, target_id);
957        assert_eq!(sim.position().quantity, 0.0);
958        assert!(sim.open_orders().find(|o| o.id == stop_id).is_none());
959    }
960
961    /// Finding 02, scenario C: a partially filled entry must never let its exits close more than
962    /// the quantity actually opened so far.
963    #[test]
964    fn test_bracket_exit_never_exceeds_partially_filled_entry_quantity() {
965        let mut sim = FillSimulator::new(FillSimulatorConfig {
966            max_fill_ratio_of_volume: Some(0.1),
967            ..Default::default()
968        });
969        let (entry_id, stop_id, _target_id) = submit_bracket(
970            &mut sim,
971            OrderSide::Buy,
972            100.0,
973            OrderKind::Market,
974            95.0,
975            110.0,
976        )
977        .unwrap();
978
979        // Bar 1: entry and stop are both capped to 10% of 500 volume = 50. Entry fills 50 (of
980        // 100), and the stop, touched the same bar, may close at most that same 50 -- not the
981        // full 100 quantity it was submitted with.
982        let fills = sim.on_bar(&bar(100.0, 100.0, 90.0, 95.0, 500.0), 0);
983        let entry_filled: f64 = fills
984            .iter()
985            .filter(|f| f.order_id == entry_id)
986            .map(|f| f.quantity)
987            .sum();
988        let stop_filled: f64 = fills
989            .iter()
990            .filter(|f| f.order_id == stop_id)
991            .map(|f| f.quantity)
992            .sum();
993        assert_eq!(entry_filled, 50.0);
994        assert!(
995            stop_filled <= entry_filled + 1e-9,
996            "exit filled {stop_filled} against only {entry_filled} of entry"
997        );
998    }
999
1000    /// Long and short brackets must behave symmetrically: the finding's scenario A mirrored for a
1001    /// sell entry with a buy-side stop/target.
1002    #[test]
1003    fn test_short_bracket_exit_cannot_fill_before_entry() {
1004        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
1005        let (_entry_id, _stop_id, target_id) = submit_bracket(
1006            &mut sim,
1007            OrderSide::Sell,
1008            10.0,
1009            OrderKind::Limit { price: 110.0 },
1010            120.0,
1011            90.0,
1012        )
1013        .unwrap();
1014
1015        // Entry (sell limit 110) is not touched (high 108 < 110), but the buy target (limit 90)
1016        // is independently marketable (low 85 <= 90).
1017        let fills = sim.on_bar(&bar(100.0, 108.0, 85.0, 95.0, 1000.0), 0);
1018        assert!(
1019            fills.is_empty(),
1020            "target must not fill before its entry: {fills:?}"
1021        );
1022        assert_eq!(sim.position().quantity, 0.0);
1023        assert_eq!(
1024            sim.open_orders()
1025                .find(|o| o.id == target_id)
1026                .unwrap()
1027                .status,
1028            OrderStatus::Pending
1029        );
1030    }
1031
1032    /// Documents current, accepted behavior (not a bug fixed by finding 02): pending same-
1033    /// direction entry orders are not counted against `max_pyramid_entries` while the position is
1034    /// flat, only already-filled positions are. Two pending brackets can therefore both go on to
1035    /// fill even with a pyramid cap of 1.
1036    #[test]
1037    fn test_pending_same_direction_entries_not_capped_by_pyramid_limit_while_flat() {
1038        let mut sim = FillSimulator::new(FillSimulatorConfig {
1039            max_pyramid_entries: Some(1),
1040            ..Default::default()
1041        });
1042        let first = sim.submit(OrderSide::Buy, OrderKind::Limit { price: 100.0 }, 5.0);
1043        let second = sim.submit(OrderSide::Buy, OrderKind::Limit { price: 100.0 }, 5.0);
1044        assert!(first.is_some());
1045        assert!(
1046            second.is_some(),
1047            "pending pyramid cap is not yet enforced pre-fill"
1048        );
1049
1050        let fills = sim.on_bar(&bar(100.0, 100.0, 99.0, 100.0, 1000.0), 0);
1051        assert_eq!(fills.len(), 2);
1052        assert_eq!(sim.position().quantity, 10.0);
1053    }
1054
1055    /// Fees/spread/slippage change fill *prices*, not the bracket linkage/capacity logic: the
1056    /// same scenario-B invariants (one exit fires, position ends flat, sibling cancelled) must
1057    /// still hold with non-zero costs configured.
1058    #[test]
1059    fn test_bracket_linkage_unaffected_by_costs() {
1060        let mut sim = FillSimulator::new(FillSimulatorConfig {
1061            costs: ExecutionCosts {
1062                fee_pct: 0.001,
1063                spread: 0.5,
1064                slippage_pct: 0.0005,
1065            },
1066            ..Default::default()
1067        });
1068        let (_entry_id, stop_id, target_id) = submit_bracket(
1069            &mut sim,
1070            OrderSide::Buy,
1071            10.0,
1072            OrderKind::Market,
1073            95.0,
1074            110.0,
1075        )
1076        .unwrap();
1077
1078        let fills = sim.on_bar(&bar(100.0, 112.0, 94.0, 105.0, 1000.0), 0);
1079        assert_eq!(fills.len(), 2);
1080        assert_eq!(fills[1].order_id, stop_id);
1081        assert_eq!(sim.position().quantity, 0.0);
1082        assert!(sim.open_orders().find(|o| o.id == target_id).is_none());
1083    }
1084}