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;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum OrderSide {
14    Buy,
15    Sell,
16}
17
18impl OrderSide {
19    fn sign(self) -> f64 {
20        match self {
21            OrderSide::Buy => 1.0,
22            OrderSide::Sell => -1.0,
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum OrderKind {
29    Market,
30    Limit {
31        price: f64,
32    },
33    Stop {
34        trigger: f64,
35    },
36    StopLimit {
37        trigger: f64,
38        limit: f64,
39    },
40    /// Trailing stop: `trail_amount` is the fixed price distance kept behind the best price seen
41    /// since the order was submitted.
42    Trailing {
43        trail_amount: f64,
44    },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum OrderStatus {
49    Pending,
50    PartiallyFilled,
51    Filled,
52    Cancelled,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct Order {
57    pub id: u64,
58    pub side: OrderSide,
59    pub kind: OrderKind,
60    pub quantity: f64,
61    pub filled_quantity: f64,
62    pub status: OrderStatus,
63    /// For `OrderKind::Trailing`: the current computed stop level, updated every bar.
64    pub trailing_stop_price: Option<f64>,
65    /// For `OrderKind::StopLimit`: `true` once the trigger has been crossed and the order behaves
66    /// as a resting limit order at `limit`.
67    pub stop_triggered: bool,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct Fill {
72    pub order_id: u64,
73    pub side: OrderSide,
74    pub price: f64,
75    pub quantity: f64,
76    pub fee: f64,
77    pub timestamp: i64,
78}
79
80/// Trading costs applied to every fill.
81#[derive(Debug, Clone, Copy, PartialEq, Default)]
82pub struct ExecutionCosts {
83    /// Fraction of notional charged as a fee per fill (e.g. `0.001` = 10 bps).
84    pub fee_pct: f64,
85    /// Fixed price spread applied against the order side (buys fill `spread/2` higher, sells
86    /// `spread/2` lower).
87    pub spread: f64,
88    /// Additional adverse slippage as a fraction of price, applied the same direction as spread.
89    pub slippage_pct: f64,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Default)]
93pub struct Position {
94    /// Positive = long, negative = short, `0.0` = flat.
95    pub quantity: f64,
96    pub avg_entry_price: f64,
97    pub realized_pnl: f64,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Default)]
101pub struct FillSimulatorConfig {
102    pub costs: ExecutionCosts,
103    /// Caps how much of a pending order's remaining quantity can fill in one bar, as a fraction
104    /// of that bar's volume (`None` = no cap, fill fully when price conditions are met). Models
105    /// participation-rate-limited partial fills.
106    pub max_fill_ratio_of_volume: Option<f64>,
107    /// Maximum number of same-direction fills accumulated into one position (pyramiding cap).
108    /// `None` = unlimited.
109    pub max_pyramid_entries: Option<u32>,
110}
111
112pub struct FillSimulator {
113    config: FillSimulatorConfig,
114    orders: Vec<Order>,
115    next_order_id: u64,
116    position: Position,
117    pyramid_entries: u32,
118    fills: Vec<Fill>,
119}
120
121impl FillSimulator {
122    pub fn new(config: FillSimulatorConfig) -> Self {
123        Self {
124            config,
125            orders: Vec::new(),
126            next_order_id: 1,
127            position: Position::default(),
128            pyramid_entries: 0,
129            fills: Vec::new(),
130        }
131    }
132
133    pub fn position(&self) -> Position {
134        self.position
135    }
136
137    pub fn fills(&self) -> &[Fill] {
138        &self.fills
139    }
140
141    pub fn open_orders(&self) -> impl Iterator<Item = &Order> {
142        self.orders.iter().filter(|o| {
143            matches!(
144                o.status,
145                OrderStatus::Pending | OrderStatus::PartiallyFilled
146            )
147        })
148    }
149
150    /// Submits a new order, returning its ID. Rejected (returns `None`) if this would exceed
151    /// `max_pyramid_entries` same-direction accumulations.
152    pub fn submit(&mut self, side: OrderSide, kind: OrderKind, quantity: f64) -> Option<u64> {
153        if quantity <= 0.0 {
154            return None;
155        }
156        let would_pyramid = self.position.quantity != 0.0
157            && self.position.quantity.signum() == side.sign()
158            && self.pyramid_entries > 0;
159        if would_pyramid {
160            if let Some(max) = self.config.max_pyramid_entries {
161                if self.pyramid_entries >= max {
162                    return None;
163                }
164            }
165        }
166
167        let id = self.next_order_id;
168        self.next_order_id += 1;
169        self.orders.push(Order {
170            id,
171            side,
172            kind,
173            quantity,
174            filled_quantity: 0.0,
175            status: OrderStatus::Pending,
176            trailing_stop_price: None,
177            stop_triggered: false,
178        });
179        Some(id)
180    }
181
182    pub fn cancel(&mut self, order_id: u64) -> bool {
183        if let Some(order) = self.orders.iter_mut().find(|o| o.id == order_id) {
184            if matches!(
185                order.status,
186                OrderStatus::Pending | OrderStatus::PartiallyFilled
187            ) {
188                order.status = OrderStatus::Cancelled;
189                return true;
190            }
191        }
192        false
193    }
194
195    /// Processes one bar against all resting orders: updates trailing stops, checks fill
196    /// conditions, applies costs, and updates position state. Returns the fills produced this
197    /// bar.
198    pub fn on_bar(&mut self, bar: &Bar, timestamp: i64) -> Vec<Fill> {
199        let mut bar_fills = Vec::new();
200        let max_fill_qty = self
201            .config
202            .max_fill_ratio_of_volume
203            .map(|r| (r * bar.volume).max(0.0));
204
205        for order in &mut self.orders {
206            if !matches!(
207                order.status,
208                OrderStatus::Pending | OrderStatus::PartiallyFilled
209            ) {
210                continue;
211            }
212
213            if let OrderKind::Trailing { trail_amount } = order.kind {
214                // Sell (exits a long): stop trails below the high, ratcheting up only.
215                // Buy (exits/covers a short): stop trails above the low, ratcheting down only.
216                let candidate = match order.side {
217                    OrderSide::Sell => bar.high - trail_amount,
218                    OrderSide::Buy => bar.low + trail_amount,
219                };
220                order.trailing_stop_price = Some(match (order.trailing_stop_price, order.side) {
221                    (Some(prev), OrderSide::Sell) => prev.max(candidate),
222                    (Some(prev), OrderSide::Buy) => prev.min(candidate),
223                    (None, _) => candidate,
224                });
225            }
226
227            if let OrderKind::StopLimit { trigger, .. } = order.kind {
228                if !order.stop_triggered {
229                    let crossed = match order.side {
230                        OrderSide::Buy => bar.high >= trigger,
231                        OrderSide::Sell => bar.low <= trigger,
232                    };
233                    if crossed {
234                        order.stop_triggered = true;
235                    }
236                }
237            }
238
239            let Some(fill_price) = fill_price_for(order, bar) else {
240                continue;
241            };
242
243            let remaining = order.quantity - order.filled_quantity;
244            let fill_qty = max_fill_qty
245                .map(|cap| remaining.min(cap))
246                .unwrap_or(remaining);
247            if fill_qty <= 0.0 {
248                continue;
249            }
250
251            let costs = &self.config.costs;
252            let side_sign = order.side.sign();
253            let executed_price = fill_price
254                * (1.0
255                    + side_sign * (costs.spread / fill_price.max(1e-9) / 2.0 + costs.slippage_pct));
256            let fee = executed_price * fill_qty * costs.fee_pct;
257
258            order.filled_quantity += fill_qty;
259            order.status = if order.filled_quantity >= order.quantity - 1e-9 {
260                OrderStatus::Filled
261            } else {
262                OrderStatus::PartiallyFilled
263            };
264
265            bar_fills.push(Fill {
266                order_id: order.id,
267                side: order.side,
268                price: executed_price,
269                quantity: fill_qty,
270                fee,
271                timestamp,
272            });
273        }
274
275        for fill in &bar_fills {
276            self.apply_fill(fill);
277        }
278        self.fills.extend(bar_fills.iter().copied());
279
280        // Terminal orders (Filled/Cancelled) no longer participate in fill checks; their history
281        // already lives in `self.fills`, so drop them here rather than rescanning them forever.
282        self.orders
283            .retain(|o| !matches!(o.status, OrderStatus::Filled | OrderStatus::Cancelled));
284
285        bar_fills
286    }
287
288    fn apply_fill(&mut self, fill: &Fill) {
289        let signed_qty = fill.quantity * fill.side.sign();
290        let prev_qty = self.position.quantity;
291        let new_qty = prev_qty + signed_qty;
292
293        if prev_qty == 0.0 || prev_qty.signum() == signed_qty.signum() {
294            // Opening or adding to a position (pyramiding): weighted-average entry price.
295            let total_cost =
296                self.position.avg_entry_price * prev_qty.abs() + fill.price * fill.quantity;
297            self.position.avg_entry_price = if new_qty.abs() > 1e-12 {
298                total_cost / new_qty.abs()
299            } else {
300                0.0
301            };
302            if prev_qty == 0.0 {
303                self.pyramid_entries = 1;
304            } else {
305                self.pyramid_entries += 1;
306            }
307        } else {
308            // Reducing, closing, or flipping.
309            let closing_qty = fill.quantity.min(prev_qty.abs());
310            let pnl_per_unit = (fill.price - self.position.avg_entry_price) * prev_qty.signum();
311            self.position.realized_pnl += pnl_per_unit * closing_qty;
312
313            if fill.quantity > prev_qty.abs() {
314                // Flip: the excess opens a new position in the opposite direction.
315                self.position.avg_entry_price = fill.price;
316                self.pyramid_entries = 1;
317            } else if new_qty.abs() < 1e-12 {
318                self.position.avg_entry_price = 0.0;
319                self.pyramid_entries = 0;
320            }
321        }
322
323        self.position.realized_pnl -= fill.fee;
324        self.position.quantity = new_qty;
325    }
326}
327
328fn fill_price_for(order: &Order, bar: &Bar) -> Option<f64> {
329    match order.kind {
330        OrderKind::Market => Some(bar.open),
331        OrderKind::Limit { price } => match order.side {
332            OrderSide::Buy if bar.low <= price => Some(price.min(bar.open)),
333            OrderSide::Sell if bar.high >= price => Some(price.max(bar.open)),
334            _ => None,
335        },
336        OrderKind::Stop { trigger } => match order.side {
337            OrderSide::Buy if bar.high >= trigger => Some(trigger.max(bar.open)),
338            OrderSide::Sell if bar.low <= trigger => Some(trigger.min(bar.open)),
339            _ => None,
340        },
341        OrderKind::StopLimit { limit, .. } => {
342            // `order.stop_triggered` is updated (and persisted across bars) by the caller before
343            // this is invoked, so a trigger crossed on an earlier bar still counts here even if
344            // price has since retreated back through the trigger level.
345            if !order.stop_triggered {
346                return None;
347            }
348            match order.side {
349                OrderSide::Buy if bar.low <= limit => Some(limit),
350                OrderSide::Sell if bar.high >= limit => Some(limit),
351                _ => None,
352            }
353        }
354        OrderKind::Trailing { .. } => {
355            let stop = order.trailing_stop_price?;
356            match order.side {
357                OrderSide::Buy if bar.high >= stop => Some(stop.max(bar.open)),
358                OrderSide::Sell if bar.low <= stop => Some(stop.min(bar.open)),
359                _ => None,
360            }
361        }
362    }
363}
364
365/// A simple bracket helper: submits an entry order plus stop-loss and take-profit exits that
366/// mirror it. Not itself stateful — callers manage the returned IDs (e.g. cancel the untouched
367/// exit once the other one fills, which this simulator does not do automatically since it has no
368/// concept of linked orders).
369pub fn submit_bracket(
370    sim: &mut FillSimulator,
371    side: OrderSide,
372    quantity: f64,
373    entry: OrderKind,
374    stop_loss_trigger: f64,
375    take_profit_price: f64,
376) -> Option<(u64, u64, u64)> {
377    let exit_side = match side {
378        OrderSide::Buy => OrderSide::Sell,
379        OrderSide::Sell => OrderSide::Buy,
380    };
381    let entry_id = sim.submit(side, entry, quantity)?;
382    let stop_id = sim.submit(
383        exit_side,
384        OrderKind::Stop {
385            trigger: stop_loss_trigger,
386        },
387        quantity,
388    )?;
389    let target_id = sim.submit(
390        exit_side,
391        OrderKind::Limit {
392            price: take_profit_price,
393        },
394        quantity,
395    )?;
396    Some((entry_id, stop_id, target_id))
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn bar(o: f64, h: f64, l: f64, c: f64, v: f64) -> Bar {
404        Bar::new(0, o, h, l, c, v)
405    }
406
407    #[test]
408    fn test_market_order_fills_at_open() {
409        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
410        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
411        let fills = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
412        assert_eq!(fills.len(), 1);
413        assert_eq!(fills[0].price, 100.0);
414        assert_eq!(sim.position().quantity, 10.0);
415        assert_eq!(sim.position().avg_entry_price, 100.0);
416    }
417
418    #[test]
419    fn test_limit_order_only_fills_when_touched() {
420        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
421        sim.submit(OrderSide::Buy, OrderKind::Limit { price: 95.0 }, 5.0);
422
423        let no_touch = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
424        assert!(no_touch.is_empty());
425
426        let touched = sim.on_bar(&bar(98.0, 99.0, 94.0, 96.0, 1000.0), 60);
427        assert_eq!(touched.len(), 1);
428        assert!(touched[0].price <= 95.0 + 1e-9);
429    }
430
431    #[test]
432    fn test_stop_order_fills_on_trigger() {
433        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
434        sim.submit(OrderSide::Sell, OrderKind::Stop { trigger: 95.0 }, 5.0);
435        let fills = sim.on_bar(&bar(98.0, 99.0, 93.0, 94.0, 1000.0), 0);
436        assert_eq!(fills.len(), 1);
437    }
438
439    #[test]
440    fn test_stop_limit_trigger_persists_across_bars_after_price_retreats() {
441        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
442        sim.submit(
443            OrderSide::Buy,
444            OrderKind::StopLimit {
445                trigger: 100.0,
446                limit: 99.0,
447            },
448            5.0,
449        );
450
451        // Bar 1: trigger crossed (high >= 100), but the limit (99) is not reached this bar
452        // (low stays at 99.5, above the 99.0 limit).
453        let first = sim.on_bar(&bar(100.0, 101.0, 99.5, 100.5, 1000.0), 0);
454        assert!(first.is_empty());
455
456        // Bar 2: price retreats below the trigger but not yet down to the limit -- a naive
457        // re-check would see high(99.4) < trigger(100) and wrongly conclude "not triggered", but
458        // the order must stay armed since it already triggered on bar 1. No fill yet since
459        // low(99.1) is still above the limit(99.0).
460        let second = sim.on_bar(&bar(99.2, 99.4, 99.1, 99.3, 1000.0), 60);
461        assert!(second.is_empty());
462
463        // Bar 3: trades down into the limit price (99); must fill using the persisted trigger.
464        let third = sim.on_bar(&bar(99.5, 100.0, 98.5, 99.0, 1000.0), 120);
465        assert_eq!(third.len(), 1);
466        assert_eq!(third[0].price, 99.0);
467    }
468
469    #[test]
470    fn test_partial_fill_capped_by_volume_ratio() {
471        let mut sim = FillSimulator::new(FillSimulatorConfig {
472            max_fill_ratio_of_volume: Some(0.1),
473            ..Default::default()
474        });
475        let id = sim
476            .submit(OrderSide::Buy, OrderKind::Market, 100.0)
477            .unwrap();
478
479        let first = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 500.0), 0);
480        assert_eq!(first[0].quantity, 50.0); // 10% of 500 volume
481        assert_eq!(
482            sim.open_orders().find(|o| o.id == id).unwrap().status,
483            OrderStatus::PartiallyFilled
484        );
485
486        let second = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 500.0), 60);
487        assert_eq!(second[0].quantity, 50.0);
488        assert!(sim.open_orders().find(|o| o.id == id).is_none());
489        assert_eq!(sim.position().quantity, 100.0);
490    }
491
492    #[test]
493    fn test_pyramiding_accumulates_weighted_average_entry() {
494        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
495        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
496        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
497        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
498        sim.on_bar(&bar(110.0, 111.0, 109.0, 110.5, 1000.0), 60);
499
500        assert_eq!(sim.position().quantity, 20.0);
501        assert!((sim.position().avg_entry_price - 105.0).abs() < 1e-9);
502    }
503
504    #[test]
505    fn test_pyramid_cap_rejects_beyond_limit() {
506        let mut sim = FillSimulator::new(FillSimulatorConfig {
507            max_pyramid_entries: Some(1),
508            ..Default::default()
509        });
510        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
511        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
512
513        let rejected = sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
514        assert!(rejected.is_none());
515    }
516
517    #[test]
518    fn test_opposite_fill_realizes_pnl_and_reduces_position() {
519        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
520        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
521        sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
522
523        sim.submit(OrderSide::Sell, OrderKind::Market, 10.0);
524        sim.on_bar(&bar(110.0, 111.0, 109.0, 110.5, 1000.0), 60);
525
526        assert_eq!(sim.position().quantity, 0.0);
527        assert!((sim.position().realized_pnl - 100.0).abs() < 1e-9); // 10 units * $10 gain
528    }
529
530    #[test]
531    fn test_fees_and_spread_reduce_pnl() {
532        let mut sim = FillSimulator::new(FillSimulatorConfig {
533            costs: ExecutionCosts {
534                fee_pct: 0.01,
535                spread: 0.0,
536                slippage_pct: 0.0,
537            },
538            ..Default::default()
539        });
540        sim.submit(OrderSide::Buy, OrderKind::Market, 10.0);
541        let fills = sim.on_bar(&bar(100.0, 101.0, 99.0, 100.5, 1000.0), 0);
542        assert!(fills[0].fee > 0.0);
543        assert!(
544            sim.position().realized_pnl < 0.0,
545            "fees alone must show as negative realized PnL"
546        );
547    }
548
549    #[test]
550    fn test_trailing_stop_tightens_and_fills() {
551        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
552        // Long position being protected by a trailing sell-stop trailing 2.0 below the high.
553        sim.submit(
554            OrderSide::Sell,
555            OrderKind::Trailing { trail_amount: 2.0 },
556            10.0,
557        );
558
559        // Each bar's own range stays under trail_amount=2.0, so neither bar's low ever reaches
560        // that same bar's freshly computed stop -- isolates "does the stop ratchet and hold"
561        // from "does a bar's own range trigger its own stop".
562        let first = sim.on_bar(&bar(100.0, 105.0, 104.0, 104.5, 1000.0), 0); // stop -> 105-2=103
563        assert!(first.is_empty());
564        let second = sim.on_bar(&bar(104.0, 108.0, 107.0, 107.5, 1000.0), 60); // stop -> max(103,106)=106
565        assert!(second.is_empty());
566
567        // Pulls back to 104: this bar's own high-2=105.5 would suggest a *lower* stop, but the
568        // ratchet must hold at 106 from the prior bar -- and 104 <= 106 triggers the fill.
569        let third = sim.on_bar(&bar(107.0, 107.5, 104.0, 105.0, 1000.0), 120);
570        assert_eq!(third.len(), 1);
571        assert!((third[0].price - 106.0).abs() < 1e-9);
572    }
573
574    #[test]
575    fn test_cancel_prevents_future_fills() {
576        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
577        let id = sim
578            .submit(OrderSide::Buy, OrderKind::Limit { price: 50.0 }, 5.0)
579            .unwrap();
580        assert!(sim.cancel(id));
581        let fills = sim.on_bar(&bar(48.0, 49.0, 45.0, 46.0, 1000.0), 0);
582        assert!(fills.is_empty());
583    }
584
585    #[test]
586    fn test_submit_bracket_creates_entry_and_two_exits() {
587        let mut sim = FillSimulator::new(FillSimulatorConfig::default());
588        let (entry, stop, target) = submit_bracket(
589            &mut sim,
590            OrderSide::Buy,
591            10.0,
592            OrderKind::Market,
593            95.0,
594            110.0,
595        )
596        .unwrap();
597        assert_ne!(entry, stop);
598        assert_ne!(stop, target);
599    }
600}