1use thiserror::Error;
7
8use crate::types::{
9 ExecutionConvention, ExecutionFill, ExecutionModel, FillPurpose, PriceQuote, Side,
10};
11
12#[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#[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 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 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 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 (FillPurpose::LimitEntry, Side::Buy) => slipped_quote.min(requested),
242 (FillPurpose::LimitEntry, Side::Sell) => slipped_quote.max(requested),
243
244 (FillPurpose::StopEntry, Side::Buy) => slipped_quote.max(requested),
247 (FillPurpose::StopEntry, Side::Sell) => slipped_quote.min(requested),
248
249 (FillPurpose::StopLoss, Side::Buy) => slipped_quote.min(requested),
251 (FillPurpose::StopLoss, Side::Sell) => slipped_quote.max(requested),
252
253 (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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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, "e(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("e(f64::NAN, 1.0)),
608 Err(ExecutionError::InvalidBid(_))
609 ));
610 assert_eq!(
611 ExecutionPricer::validate_quote("e(1.0, 0.0)),
612 Err(ExecutionError::InvalidAsk(0.0))
613 );
614 assert_eq!(
615 ExecutionPricer::validate_quote("e(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}