Skip to main content

qs_core/
execution.rs

1//! Pure, deterministic execution pricing.
2//!
3//! This module is intentionally independent from `TradeEngine`: callers can
4//! price a fill from a quote without mutating positions or engine state.
5
6use thiserror::Error;
7
8use crate::types::{
9    ExecutionConvention, ExecutionFill, ExecutionModel, FillPurpose, PriceQuote, Side,
10};
11
12/// Errors produced while validating an execution pricing request.
13#[derive(Debug, Clone, PartialEq, Error)]
14pub enum ExecutionError {
15    #[error("quote bid must be finite and positive, got {0}")]
16    InvalidBid(f64),
17    #[error("quote ask must be finite and positive, got {0}")]
18    InvalidAsk(f64),
19    #[error("crossed quote: bid {bid} is greater than ask {ask}")]
20    CrossedQuote { bid: f64, ask: f64 },
21    #[error("pip_size must be finite and positive, got {0}")]
22    InvalidPipSize(f64),
23    #[error("slippage pips must be finite, got {0}")]
24    InvalidSlippage(f64),
25    #[error("{0:?} requires a requested price")]
26    MissingRequestedPrice(FillPurpose),
27    #[error("requested price must be finite and positive, got {0}")]
28    InvalidRequestedPrice(f64),
29}
30
31pub type ExecutionResult<T> = std::result::Result<T, ExecutionError>;
32
33/// Pure execution-price calculator configured by an [`ExecutionModel`].
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ExecutionPricer {
36    model: ExecutionModel,
37}
38
39impl ExecutionPricer {
40    pub const fn new(model: ExecutionModel) -> Self {
41        Self { model }
42    }
43
44    pub const fn model(&self) -> ExecutionModel {
45        self.model
46    }
47
48    /// Validate a quote independently of pricing it.
49    pub fn validate_quote(quote: &PriceQuote) -> ExecutionResult<()> {
50        if !quote.bid.is_finite() || quote.bid <= 0.0 {
51            return Err(ExecutionError::InvalidBid(quote.bid));
52        }
53        if !quote.ask.is_finite() || quote.ask <= 0.0 {
54            return Err(ExecutionError::InvalidAsk(quote.ask));
55        }
56        if quote.bid > quote.ask {
57            return Err(ExecutionError::CrossedQuote {
58                bid: quote.bid,
59                ask: quote.ask,
60            });
61        }
62        Ok(())
63    }
64
65    /// Price one fill. Market purposes ignore `requested_price`; all other
66    /// purposes require it.
67    pub fn price(
68        &self,
69        purpose: FillPurpose,
70        side: Side,
71        quote: &PriceQuote,
72        requested_price: Option<f64>,
73        pip_size: f64,
74    ) -> ExecutionResult<ExecutionFill> {
75        Self::validate_quote(quote)?;
76        if !pip_size.is_finite() || pip_size <= 0.0 {
77            return Err(ExecutionError::InvalidPipSize(pip_size));
78        }
79
80        let slippage_pips = self.model.slippage.pips();
81        if !slippage_pips.is_finite() {
82            return Err(ExecutionError::InvalidSlippage(slippage_pips));
83        }
84
85        let requested_price = if purpose.requires_requested_price() {
86            let requested =
87                requested_price.ok_or(ExecutionError::MissingRequestedPrice(purpose))?;
88            if !requested.is_finite() || requested <= 0.0 {
89                return Err(ExecutionError::InvalidRequestedPrice(requested));
90            }
91            Some(requested)
92        } else {
93            None
94        };
95
96        let quote_price = if purpose.is_entry() {
97            quote.fill_price(side, self.model.fill_model)
98        } else {
99            quote.eval_price(side, self.model.fill_model)
100        };
101
102        let price = match self.model.convention {
103            ExecutionConvention::Legacy => {
104                let base = requested_price.unwrap_or(quote_price);
105                apply_slippage(base, purpose, side, slippage_pips, pip_size)
106            }
107            ExecutionConvention::FutureQuoteV1 => {
108                let slipped_quote =
109                    apply_slippage(quote_price, purpose, side, slippage_pips, pip_size);
110                future_quote_price(purpose, side, slipped_quote, requested_price)
111            }
112        };
113
114        Ok(ExecutionFill {
115            purpose,
116            side,
117            price,
118            quote_price,
119            requested_price,
120            slippage_pips,
121        })
122    }
123
124    pub fn market_entry(
125        &self,
126        side: Side,
127        quote: &PriceQuote,
128        pip_size: f64,
129    ) -> ExecutionResult<ExecutionFill> {
130        self.price(FillPurpose::MarketEntry, side, quote, None, pip_size)
131    }
132
133    pub fn market_exit(
134        &self,
135        side: Side,
136        quote: &PriceQuote,
137        pip_size: f64,
138    ) -> ExecutionResult<ExecutionFill> {
139        self.price(FillPurpose::MarketExit, side, quote, None, pip_size)
140    }
141
142    pub fn limit_entry(
143        &self,
144        side: Side,
145        quote: &PriceQuote,
146        limit_price: f64,
147        pip_size: f64,
148    ) -> ExecutionResult<ExecutionFill> {
149        self.price(
150            FillPurpose::LimitEntry,
151            side,
152            quote,
153            Some(limit_price),
154            pip_size,
155        )
156    }
157
158    pub fn stop_entry(
159        &self,
160        side: Side,
161        quote: &PriceQuote,
162        stop_price: f64,
163        pip_size: f64,
164    ) -> ExecutionResult<ExecutionFill> {
165        self.price(
166            FillPurpose::StopEntry,
167            side,
168            quote,
169            Some(stop_price),
170            pip_size,
171        )
172    }
173
174    pub fn stop_loss(
175        &self,
176        side: Side,
177        quote: &PriceQuote,
178        stop_price: f64,
179        pip_size: f64,
180    ) -> ExecutionResult<ExecutionFill> {
181        self.price(
182            FillPurpose::StopLoss,
183            side,
184            quote,
185            Some(stop_price),
186            pip_size,
187        )
188    }
189
190    pub fn take_profit(
191        &self,
192        side: Side,
193        quote: &PriceQuote,
194        target_price: f64,
195        pip_size: f64,
196    ) -> ExecutionResult<ExecutionFill> {
197        self.price(
198            FillPurpose::TakeProfit,
199            side,
200            quote,
201            Some(target_price),
202            pip_size,
203        )
204    }
205}
206
207impl Default for ExecutionPricer {
208    fn default() -> Self {
209        Self::new(ExecutionModel::default())
210    }
211}
212
213fn apply_slippage(
214    price: f64,
215    purpose: FillPurpose,
216    side: Side,
217    signed_pips: f64,
218    pip_size: f64,
219) -> f64 {
220    // Positive pips always move against the position: entries cost more and
221    // exits realize less. A negative value reverses the movement.
222    let adverse_sign = match (purpose.is_entry(), side) {
223        (true, Side::Buy) | (false, Side::Sell) => 1.0,
224        (true, Side::Sell) | (false, Side::Buy) => -1.0,
225    };
226    price + adverse_sign * signed_pips * pip_size
227}
228
229fn future_quote_price(
230    purpose: FillPurpose,
231    side: Side,
232    slipped_quote: f64,
233    requested_price: Option<f64>,
234) -> f64 {
235    let Some(requested) = requested_price else {
236        return slipped_quote;
237    };
238
239    match (purpose, side) {
240        // A limit is never filled worse than its cap, but a better quote is kept.
241        (FillPurpose::LimitEntry, Side::Buy) => slipped_quote.min(requested),
242        (FillPurpose::LimitEntry, Side::Sell) => slipped_quote.max(requested),
243
244        // Stop orders keep an adverse opening gap and never manufacture a
245        // favorable fill beyond the trigger.
246        (FillPurpose::StopEntry, Side::Buy) => slipped_quote.max(requested),
247        (FillPurpose::StopEntry, Side::Sell) => slipped_quote.min(requested),
248
249        // A protective stop keeps an adverse closing gap.
250        (FillPurpose::StopLoss, Side::Buy) => slipped_quote.min(requested),
251        (FillPurpose::StopLoss, Side::Sell) => slipped_quote.max(requested),
252
253        // A take-profit behaves as a price-protected exit: keep favorable gap
254        // improvement, but never fill worse than the target.
255        (FillPurpose::TakeProfit, Side::Buy) => slipped_quote.max(requested),
256        (FillPurpose::TakeProfit, Side::Sell) => slipped_quote.min(requested),
257
258        (FillPurpose::MarketEntry | FillPurpose::MarketExit, _) => slipped_quote,
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use chrono::NaiveDate;
265
266    use super::*;
267    use crate::types::{FillModel, SlippageModel};
268
269    const PIP: f64 = 0.0001;
270
271    fn quote(bid: f64, ask: f64) -> PriceQuote {
272        PriceQuote {
273            symbol: "EURUSD".into(),
274            ts: NaiveDate::from_ymd_opt(2026, 1, 1)
275                .unwrap()
276                .and_hms_opt(12, 0, 0)
277                .unwrap(),
278            bid,
279            ask,
280        }
281    }
282
283    fn future(fill_model: FillModel) -> ExecutionPricer {
284        ExecutionPricer::new(ExecutionModel::future_quote_v1(fill_model))
285    }
286
287    fn with_slippage(pips: f64) -> ExecutionPricer {
288        ExecutionPricer::new(ExecutionModel::new(
289            ExecutionConvention::FutureQuoteV1,
290            FillModel::BidAsk,
291            SlippageModel::FixedPips { pips },
292        ))
293    }
294
295    fn assert_price(actual: f64, expected: f64) {
296        assert!(
297            (actual - expected).abs() < 1e-12,
298            "expected {expected}, got {actual}"
299        );
300    }
301
302    #[test]
303    fn defaults_preserve_legacy_bid_ask_without_slippage() {
304        let model = ExecutionModel::default();
305        assert_eq!(model.convention, ExecutionConvention::Legacy);
306        assert_eq!(model.fill_model, FillModel::BidAsk);
307        assert_eq!(model.slippage, SlippageModel::None);
308        assert_eq!(ExecutionPricer::default().model(), model);
309    }
310
311    #[test]
312    fn market_entry_uses_opening_side_for_both_sides() {
313        let q = quote(1.1000, 1.1002);
314        assert_price(
315            future(FillModel::BidAsk)
316                .market_entry(Side::Buy, &q, PIP)
317                .unwrap()
318                .price,
319            1.1002,
320        );
321        assert_price(
322            future(FillModel::BidAsk)
323                .market_entry(Side::Sell, &q, PIP)
324                .unwrap()
325                .price,
326            1.1000,
327        );
328    }
329
330    #[test]
331    fn market_exit_uses_closing_side_for_both_sides() {
332        let q = quote(1.1000, 1.1002);
333        assert_price(
334            future(FillModel::BidAsk)
335                .market_exit(Side::Buy, &q, PIP)
336                .unwrap()
337                .price,
338            1.1000,
339        );
340        assert_price(
341            future(FillModel::BidAsk)
342                .market_exit(Side::Sell, &q, PIP)
343                .unwrap()
344                .price,
345            1.1002,
346        );
347    }
348
349    #[test]
350    fn ask_only_and_mid_price_are_honored() {
351        let q = quote(1.1000, 1.1004);
352        for side in [Side::Buy, Side::Sell] {
353            assert_price(
354                future(FillModel::AskOnly)
355                    .market_exit(side, &q, PIP)
356                    .unwrap()
357                    .price,
358                1.1004,
359            );
360            assert_price(
361                future(FillModel::MidPrice)
362                    .market_entry(side, &q, PIP)
363                    .unwrap()
364                    .price,
365                1.1002,
366            );
367        }
368    }
369
370    #[test]
371    fn buy_limit_keeps_improvement_and_caps_adverse_price() {
372        let pricer = future(FillModel::BidAsk);
373        assert_price(
374            pricer
375                .limit_entry(Side::Buy, &quote(1.0988, 1.0990), 1.1000, PIP)
376                .unwrap()
377                .price,
378            1.0990,
379        );
380        assert_price(
381            pricer
382                .limit_entry(Side::Buy, &quote(1.1000, 1.1002), 1.1000, PIP)
383                .unwrap()
384                .price,
385            1.1000,
386        );
387    }
388
389    #[test]
390    fn sell_limit_keeps_improvement_and_caps_adverse_price() {
391        let pricer = future(FillModel::BidAsk);
392        assert_price(
393            pricer
394                .limit_entry(Side::Sell, &quote(1.1010, 1.1012), 1.1000, PIP)
395                .unwrap()
396                .price,
397            1.1010,
398        );
399        assert_price(
400            pricer
401                .limit_entry(Side::Sell, &quote(1.0998, 1.1000), 1.1000, PIP)
402                .unwrap()
403                .price,
404            1.1000,
405        );
406    }
407
408    #[test]
409    fn stop_entry_keeps_adverse_gaps_for_both_sides() {
410        let pricer = future(FillModel::BidAsk);
411        assert_price(
412            pricer
413                .stop_entry(Side::Buy, &quote(1.1008, 1.1010), 1.1000, PIP)
414                .unwrap()
415                .price,
416            1.1010,
417        );
418        assert_price(
419            pricer
420                .stop_entry(Side::Sell, &quote(1.0990, 1.0992), 1.1000, PIP)
421                .unwrap()
422                .price,
423            1.0990,
424        );
425    }
426
427    #[test]
428    fn stop_entry_does_not_manufacture_favorable_gap() {
429        let pricer = future(FillModel::BidAsk);
430        assert_price(
431            pricer
432                .stop_entry(Side::Buy, &quote(1.0996, 1.0998), 1.1000, PIP)
433                .unwrap()
434                .price,
435            1.1000,
436        );
437        assert_price(
438            pricer
439                .stop_entry(Side::Sell, &quote(1.1002, 1.1004), 1.1000, PIP)
440                .unwrap()
441                .price,
442            1.1000,
443        );
444    }
445
446    #[test]
447    fn stop_loss_keeps_adverse_gaps_for_both_sides() {
448        let pricer = future(FillModel::BidAsk);
449        assert_price(
450            pricer
451                .stop_loss(Side::Buy, &quote(1.0988, 1.0990), 1.1000, PIP)
452                .unwrap()
453                .price,
454            1.0988,
455        );
456        assert_price(
457            pricer
458                .stop_loss(Side::Sell, &quote(1.1010, 1.1012), 1.1000, PIP)
459                .unwrap()
460                .price,
461            1.1012,
462        );
463    }
464
465    #[test]
466    fn take_profit_keeps_favorable_gap_and_target_cap() {
467        let pricer = future(FillModel::BidAsk);
468        assert_price(
469            pricer
470                .take_profit(Side::Buy, &quote(1.1020, 1.1022), 1.1000, PIP)
471                .unwrap()
472                .price,
473            1.1020,
474        );
475        assert_price(
476            pricer
477                .take_profit(Side::Buy, &quote(1.0998, 1.1000), 1.1000, PIP)
478                .unwrap()
479                .price,
480            1.1000,
481        );
482        assert_price(
483            pricer
484                .take_profit(Side::Sell, &quote(1.0988, 1.0990), 1.1000, PIP)
485                .unwrap()
486                .price,
487            1.0990,
488        );
489        assert_price(
490            pricer
491                .take_profit(Side::Sell, &quote(1.1000, 1.1002), 1.1000, PIP)
492                .unwrap()
493                .price,
494            1.1000,
495        );
496    }
497
498    #[test]
499    fn legacy_non_market_fills_use_requested_price() {
500        let pricer = ExecutionPricer::default();
501        let q = quote(1.0988, 1.0990);
502        assert_price(
503            pricer
504                .limit_entry(Side::Buy, &q, 1.1000, PIP)
505                .unwrap()
506                .price,
507            1.1000,
508        );
509        assert_price(
510            pricer
511                .stop_entry(Side::Buy, &quote(1.1010, 1.1012), 1.1000, PIP)
512                .unwrap()
513                .price,
514            1.1000,
515        );
516        assert_price(
517            pricer.stop_loss(Side::Buy, &q, 1.1000, PIP).unwrap().price,
518            1.1000,
519        );
520        assert_price(
521            pricer
522                .take_profit(Side::Buy, &quote(1.1020, 1.1022), 1.1000, PIP)
523                .unwrap()
524                .price,
525            1.1000,
526        );
527    }
528
529    #[test]
530    fn adverse_slippage_moves_entries_against_both_sides() {
531        let q = quote(1.1000, 1.1002);
532        let pricer = with_slippage(2.0);
533        assert_price(
534            pricer.market_entry(Side::Buy, &q, PIP).unwrap().price,
535            1.1004,
536        );
537        assert_price(
538            pricer.market_entry(Side::Sell, &q, PIP).unwrap().price,
539            1.0998,
540        );
541    }
542
543    #[test]
544    fn adverse_slippage_moves_exits_against_both_sides() {
545        let q = quote(1.1000, 1.1002);
546        let pricer = with_slippage(2.0);
547        assert_price(
548            pricer.market_exit(Side::Buy, &q, PIP).unwrap().price,
549            1.0998,
550        );
551        assert_price(
552            pricer.market_exit(Side::Sell, &q, PIP).unwrap().price,
553            1.1004,
554        );
555    }
556
557    #[test]
558    fn favorable_slippage_reverses_the_adverse_direction() {
559        let q = quote(1.1000, 1.1002);
560        let pricer = with_slippage(-2.0);
561        assert_price(
562            pricer.market_entry(Side::Buy, &q, PIP).unwrap().price,
563            1.1000,
564        );
565        assert_price(
566            pricer.market_entry(Side::Sell, &q, PIP).unwrap().price,
567            1.1002,
568        );
569        assert_price(
570            pricer.market_exit(Side::Buy, &q, PIP).unwrap().price,
571            1.1002,
572        );
573        assert_price(
574            pricer.market_exit(Side::Sell, &q, PIP).unwrap().price,
575            1.1000,
576        );
577    }
578
579    #[test]
580    fn limit_cap_is_preserved_after_adverse_slippage() {
581        let q = quote(1.0998, 1.1000);
582        let pricer = with_slippage(5.0);
583        assert_price(
584            pricer
585                .limit_entry(Side::Buy, &q, 1.1002, PIP)
586                .unwrap()
587                .price,
588            1.1002,
589        );
590    }
591
592    #[test]
593    fn execution_fill_reports_inputs_and_selected_quote() {
594        let fill = with_slippage(1.5)
595            .stop_loss(Side::Buy, &quote(1.0990, 1.0992), 1.1000, PIP)
596            .unwrap();
597        assert_eq!(fill.purpose, FillPurpose::StopLoss);
598        assert_eq!(fill.side, Side::Buy);
599        assert_eq!(fill.requested_price, Some(1.1000));
600        assert_eq!(fill.quote_price, 1.0990);
601        assert_eq!(fill.slippage_pips, 1.5);
602    }
603
604    #[test]
605    fn rejects_non_finite_non_positive_and_crossed_quotes() {
606        assert!(matches!(
607            ExecutionPricer::validate_quote(&quote(f64::NAN, 1.0)),
608            Err(ExecutionError::InvalidBid(_))
609        ));
610        assert_eq!(
611            ExecutionPricer::validate_quote(&quote(1.0, 0.0)),
612            Err(ExecutionError::InvalidAsk(0.0))
613        );
614        assert_eq!(
615            ExecutionPricer::validate_quote(&quote(1.1, 1.0)),
616            Err(ExecutionError::CrossedQuote { bid: 1.1, ask: 1.0 })
617        );
618    }
619
620    #[test]
621    fn rejects_invalid_pip_size_and_slippage() {
622        let q = quote(1.0, 1.1);
623        assert_eq!(
624            future(FillModel::BidAsk).market_entry(Side::Buy, &q, 0.0),
625            Err(ExecutionError::InvalidPipSize(0.0))
626        );
627        let pricer = with_slippage(f64::INFINITY);
628        assert!(matches!(
629            pricer.market_entry(Side::Buy, &q, PIP),
630            Err(ExecutionError::InvalidSlippage(_))
631        ));
632    }
633
634    #[test]
635    fn validates_requested_price_only_when_required() {
636        let q = quote(1.0, 1.1);
637        let pricer = future(FillModel::BidAsk);
638        assert_eq!(
639            pricer.price(FillPurpose::LimitEntry, Side::Buy, &q, None, PIP),
640            Err(ExecutionError::MissingRequestedPrice(
641                FillPurpose::LimitEntry
642            ))
643        );
644        assert_eq!(
645            pricer.limit_entry(Side::Buy, &q, 0.0, PIP),
646            Err(ExecutionError::InvalidRequestedPrice(0.0))
647        );
648        assert!(
649            pricer
650                .price(FillPurpose::MarketEntry, Side::Buy, &q, Some(f64::NAN), PIP)
651                .is_ok()
652        );
653    }
654}