options_common/
lib.rs

1use chrono::{DateTime, Duration, NaiveDate, Utc};
2use enum_dispatch::enum_dispatch;
3use num_rational::Rational64;
4use num_traits::{Signed, ToPrimitive, Zero};
5use ordered_float::NotNan;
6
7use std::convert::TryInto;
8use std::error::Error;
9use std::fmt;
10use std::str::FromStr;
11
12const SHARE_UNIT_DELTA: f64 = 0.01;
13
14#[enum_dispatch]
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub enum Position {
17    OptionsPosition,
18    SharesPosition,
19}
20
21/// Defines methods that are shared between option and share positions.
22#[enum_dispatch(Position)]
23pub trait GenericPosition {
24    /// For an option position, the symbol of the option itself. For a share position, equal to [`Self::underlying_symbol()`].
25    fn symbol(&self) -> &str;
26
27    /// For an option position, the symbol of the instrument that the option is a derivative of.
28    /// For a share position, the symbol of the stock.
29    fn underlying_symbol(&self) -> &str;
30
31    /// Whether the position is long or short the underlying.
32    fn is_long(&self) -> bool;
33
34    /// The original cost per option contract or share in this position. If the position is long, this should be negative.
35    fn unit_cost(&self) -> Option<Rational64>;
36    fn unit_cost_mut(&mut self) -> &mut Option<Rational64>;
37
38    /// The current bid price per option contract or share in this position. If the position is long, this should be positive.
39    fn unit_bid_price(&self) -> Option<Rational64>;
40    fn unit_bid_price_mut(&mut self) -> &mut Option<Rational64>;
41
42    /// The current ask price per option contract or share in this position. If the position is long, this should be positive.
43    fn unit_ask_price(&self) -> Option<Rational64>;
44    fn unit_ask_price_mut(&mut self) -> &mut Option<Rational64>;
45
46    /// The delta per option contract or share in this position, where the delta equivalent of 1 share == 0.01.
47    fn unit_delta(&self) -> Option<NotNan<f64>>;
48
49    /// The vega per option contract or share in this position.
50    fn unit_vega(&self) -> Option<NotNan<f64>>;
51
52    /// The theta per option contract in this position.
53    fn unit_theta(&self) -> Option<NotNan<f64>>;
54
55    /// The number of option contracts or shares in this position.
56    fn quantity(&self) -> Rational64;
57    fn quantity_mut(&mut self) -> &mut Rational64;
58
59    /// Equal to [`Self::quantity()`], but negative if the position is short.
60    fn signed_quantity(&self) -> Rational64 {
61        if self.is_long() {
62            self.quantity()
63        } else {
64            -self.quantity()
65        }
66    }
67
68    /// The total original cost of all option contracts or shares in this position.
69    fn cost(&self) -> Option<Rational64> {
70        self.unit_cost().map(|x| x * self.quantity())
71    }
72
73    fn net_liq(&self) -> Option<Rational64> {
74        self.unit_mid_price().map(|x| x * self.quantity())
75    }
76
77    /// The total current bid price for all option contracts or shares in this position.
78    fn bid_price(&self) -> Option<Rational64> {
79        self.unit_bid_price().map(|x| x * self.quantity())
80    }
81
82    /// The total current ask price for all option contracts or shares in this position.
83    fn ask_price(&self) -> Option<Rational64> {
84        self.unit_ask_price().map(|x| x * self.quantity())
85    }
86
87    /// The total current mid price for all option contracts or shares in this position.
88    fn mid_price(&self) -> Option<Rational64> {
89        self.unit_mid_price().map(|x| x * self.quantity())
90    }
91
92    /// The current mid price per option contract or share in this position. If the position is long, this will be positive.
93    fn unit_mid_price(&self) -> Option<Rational64> {
94        Some((self.unit_bid_price()? + self.unit_ask_price()?) / 2)
95    }
96
97    /// The total delta for all option contracts or shares in this position, where the delta equivalent of 1 share == 0.01.
98    fn delta(&self) -> Option<NotNan<f64>> {
99        Some(self.unit_delta()? * self.quantity().to_f64()?)
100    }
101
102    /// The total vega for all option contracts or shares in this position.
103    fn vega(&self) -> Option<NotNan<f64>> {
104        Some(self.unit_vega()? * self.quantity().to_f64()?)
105    }
106
107    /// The total theta for all option contracts or shares in this position.
108    fn theta(&self) -> Option<NotNan<f64>> {
109        Some(self.unit_theta()? * self.quantity().to_f64()?)
110    }
111
112    /// For an option position, the strike price of the option. For a share position, the strike
113    /// price of the call option with the equivalent cost at the time of purchase i.e. $0.
114    fn equivalent_strike_price(&self) -> Rational64;
115
116    /// For an option position, the type of the option. For a share position, equal to [`OptionType::Call`].
117    fn equivalent_option_type(&self) -> OptionType;
118
119    /// For an option position, the number of units of the underlying per option contract. For a
120    /// share position, equal to 1.
121    fn equivalent_lot_size(&self) -> i64;
122
123    fn profit_at_expiry(&self, underlying_price: Rational64) -> Rational64 {
124        let lot_size: i64 = self.equivalent_lot_size().try_into().unwrap();
125
126        let unit_expiry_net_liq = match self.equivalent_option_type() {
127            OptionType::Call => underlying_price - self.equivalent_strike_price(),
128            OptionType::Put => self.equivalent_strike_price() - underlying_price,
129        }
130        .max(Rational64::zero())
131            * if self.is_long() { lot_size } else { -lot_size };
132
133        (self.unit_cost().expect("Undefined cost") + unit_expiry_net_liq) * self.quantity()
134    }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct OptionsPosition {
139    /// The symbol of the option itself.
140    pub symbol: String,
141
142    /// The symbol of the instrument that the option is a derivative of.
143    pub underlying_symbol: String,
144
145    /// Whether the position is long or short the underlying.
146    pub is_long: bool,
147
148    /// The original cost per option contract in this position. If the position is long, this should be negative.
149    pub unit_cost: Option<Rational64>,
150
151    /// The current bid price per option contract in this position. If the position is long, this should be positive.
152    pub unit_bid_price: Option<Rational64>,
153
154    /// The current ask price per option contract in this position. If the position is long, this should be positive.
155    pub unit_ask_price: Option<Rational64>,
156
157    /// The delta per option contract in this position, where the delta equivalent of 1 share == 0.01.
158    pub unit_delta: Option<NotNan<f64>>,
159
160    /// The vega per option contract in this position.
161    pub unit_vega: Option<NotNan<f64>>,
162
163    /// The theta per option contract in this position.
164    pub unit_theta: Option<NotNan<f64>>,
165
166    /// The number of option contracts in this position.
167    pub quantity: Rational64,
168
169    /// The strike price of the option.
170    pub strike_price: Rational64,
171
172    /// The type of the option.
173    pub option_type: OptionType,
174
175    /// The expiration date of the option.
176    pub expiration_date: ExpirationDate,
177
178    /// The number of units of the underlying per option contract in this position. Assumed to be 100 if not defined.
179    pub lot_size: Option<i64>,
180}
181
182impl OptionsPosition {
183    pub fn description(&self) -> String {
184        format!(
185            "{} {:.2} {:?}",
186            self.expiration_date,
187            self.strike_price.to_f64().unwrap(),
188            self.option_type,
189        )
190    }
191
192    #[cfg(test)]
193    pub fn mock(
194        option_type: OptionType,
195        strike_price: i64,
196        unit_cost: i64,
197        quantity: Rational64,
198    ) -> Position {
199        let is_long = unit_cost < 0;
200        OptionsPosition {
201            symbol: "OPTION".to_string(),
202            underlying_symbol: "ABC".to_string(),
203            option_type,
204            strike_price: Rational64::from_integer(strike_price),
205            expiration_date: Default::default(),
206            is_long,
207            unit_cost: Some(Rational64::from_integer(unit_cost)),
208            unit_bid_price: None,
209            unit_ask_price: None,
210            unit_delta: None,
211            unit_vega: None,
212            unit_theta: None,
213            quantity,
214            lot_size: None,
215        }
216        .into()
217    }
218}
219
220impl GenericPosition for OptionsPosition {
221    fn symbol(&self) -> &str {
222        &self.symbol
223    }
224
225    fn underlying_symbol(&self) -> &str {
226        &self.underlying_symbol
227    }
228
229    fn is_long(&self) -> bool {
230        self.is_long
231    }
232
233    fn unit_cost(&self) -> Option<Rational64> {
234        self.unit_cost
235    }
236
237    fn unit_cost_mut(&mut self) -> &mut Option<Rational64> {
238        &mut self.unit_cost
239    }
240
241    fn unit_bid_price(&self) -> Option<Rational64> {
242        self.unit_bid_price
243    }
244
245    fn unit_bid_price_mut(&mut self) -> &mut Option<Rational64> {
246        &mut self.unit_bid_price
247    }
248
249    fn unit_ask_price(&self) -> Option<Rational64> {
250        self.unit_ask_price
251    }
252
253    fn unit_ask_price_mut(&mut self) -> &mut Option<Rational64> {
254        &mut self.unit_ask_price
255    }
256
257    fn unit_delta(&self) -> Option<NotNan<f64>> {
258        self.unit_delta
259    }
260
261    fn unit_vega(&self) -> Option<NotNan<f64>> {
262        self.unit_vega
263    }
264
265    fn unit_theta(&self) -> Option<NotNan<f64>> {
266        self.unit_theta
267    }
268
269    fn quantity(&self) -> Rational64 {
270        self.quantity
271    }
272
273    fn quantity_mut(&mut self) -> &mut Rational64 {
274        &mut self.quantity
275    }
276
277    fn equivalent_strike_price(&self) -> Rational64 {
278        self.strike_price
279    }
280
281    fn equivalent_option_type(&self) -> OptionType {
282        self.option_type
283    }
284
285    fn equivalent_lot_size(&self) -> i64 {
286        self.lot_size.unwrap_or(100)
287    }
288}
289
290#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct SharesPosition {
292    /// The symbol of the stock.
293    pub symbol: String,
294
295    /// Whether the position is long or short the underlying.
296    pub is_long: bool,
297
298    /// The original cost per share in this position. If the position is long, this should be negative.
299    pub unit_cost: Option<Rational64>,
300
301    /// The current bid price per share in this position. If the position is long, this should be positive.
302    pub unit_bid_price: Option<Rational64>,
303
304    /// The current ask price per share in this position. If the position is long, this should be positive.
305    pub unit_ask_price: Option<Rational64>,
306
307    /// The number of shares in this position.
308    pub quantity: Rational64,
309}
310
311impl SharesPosition {
312    #[cfg(test)]
313    pub fn mock(unit_cost: i64, quantity: Rational64) -> Position {
314        let is_long = unit_cost < 0;
315        SharesPosition {
316            symbol: "ABC".to_string(),
317            is_long,
318            unit_cost: Some(Rational64::from_integer(unit_cost)),
319            unit_bid_price: None,
320            unit_ask_price: None,
321            quantity,
322        }
323        .into()
324    }
325}
326
327impl GenericPosition for SharesPosition {
328    fn symbol(&self) -> &str {
329        &self.symbol
330    }
331
332    fn underlying_symbol(&self) -> &str {
333        &self.symbol
334    }
335
336    fn is_long(&self) -> bool {
337        self.is_long
338    }
339
340    fn unit_cost(&self) -> Option<Rational64> {
341        self.unit_cost
342    }
343
344    fn unit_cost_mut(&mut self) -> &mut Option<Rational64> {
345        &mut self.unit_cost
346    }
347
348    fn unit_bid_price(&self) -> Option<Rational64> {
349        self.unit_bid_price
350    }
351
352    fn unit_bid_price_mut(&mut self) -> &mut Option<Rational64> {
353        &mut self.unit_bid_price
354    }
355
356    fn unit_ask_price(&self) -> Option<Rational64> {
357        self.unit_ask_price
358    }
359
360    fn unit_ask_price_mut(&mut self) -> &mut Option<Rational64> {
361        &mut self.unit_ask_price
362    }
363
364    fn unit_delta(&self) -> Option<NotNan<f64>> {
365        Some(
366            NotNan::new(if self.is_long {
367                SHARE_UNIT_DELTA
368            } else {
369                -SHARE_UNIT_DELTA
370            })
371            .unwrap(),
372        )
373    }
374
375    fn unit_vega(&self) -> Option<NotNan<f64>> {
376        Some(NotNan::new(0.0).unwrap())
377    }
378
379    fn unit_theta(&self) -> Option<NotNan<f64>> {
380        Some(NotNan::new(0.0).unwrap())
381    }
382
383    fn quantity(&self) -> Rational64 {
384        self.quantity
385    }
386
387    fn quantity_mut(&mut self) -> &mut Rational64 {
388        &mut self.quantity
389    }
390
391    fn equivalent_strike_price(&self) -> Rational64 {
392        Rational64::zero()
393    }
394
395    fn equivalent_option_type(&self) -> OptionType {
396        OptionType::Call
397    }
398
399    fn equivalent_lot_size(&self) -> i64 {
400        1
401    }
402}
403
404#[derive(Copy, Clone, Debug, Eq, PartialEq)]
405pub struct Decimal(pub Rational64);
406
407impl Decimal {
408    pub fn abs(&self) -> Decimal {
409        Decimal(self.0.abs())
410    }
411}
412
413#[derive(Debug, Clone)]
414struct DecimalFromStrError(String);
415
416impl Error for DecimalFromStrError {}
417
418impl fmt::Display for DecimalFromStrError {
419    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
420        write!(f, "'{}' could not be parsed as decimal", self.0)
421    }
422}
423
424impl FromStr for Decimal {
425    type Err = Box<dyn Error>;
426
427    fn from_str(s: &str) -> Result<Self, Self::Err> {
428        let without_commas = s.trim().replace(',', "");
429        let has_negative_sign = without_commas.starts_with('-');
430        let without_sign = without_commas.replace('-', "").replace('+', "");
431        let decimal_idx = without_sign.chars().position(|c| c == '.');
432        let without_decimal = without_sign.replace('.', "");
433
434        let mut numerator =
435            i64::from_str(&without_decimal).map_err(|_| DecimalFromStrError(s.to_string()))?;
436        if has_negative_sign {
437            numerator *= -1;
438        }
439
440        let denominator = if let Some(decimal_idx) = decimal_idx {
441            10i64.pow(
442                without_decimal
443                    .len()
444                    .checked_sub(decimal_idx)
445                    .and_then(|d| TryInto::<u32>::try_into(d).ok())
446                    .ok_or_else(|| DecimalFromStrError(s.to_string()))?,
447            )
448        } else {
449            1
450        };
451        Ok(Decimal(Rational64::new(numerator, denominator)))
452    }
453}
454
455impl fmt::Display for Decimal {
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        write!(f, "{}", self.0.to_f64().unwrap())
458    }
459}
460
461impl Default for Decimal {
462    fn default() -> Self {
463        Decimal(Rational64::zero())
464    }
465}
466
467#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
468pub struct ExpirationDate(pub NaiveDate);
469
470impl ExpirationDate {
471    pub fn time_to_expiration(&self, now: Option<fn() -> DateTime<Utc>>) -> Duration {
472        let date_now = now.unwrap_or(Utc::now)().naive_utc().date();
473        self.0 - date_now
474    }
475}
476
477impl FromStr for ExpirationDate {
478    type Err = chrono::ParseError;
479
480    fn from_str(s: &str) -> Result<Self, Self::Err> {
481        Ok(ExpirationDate(NaiveDate::from_str(s)?))
482    }
483}
484
485impl Default for ExpirationDate {
486    fn default() -> Self {
487        ExpirationDate(NaiveDate::from_ymd(1, 1, 1))
488    }
489}
490
491impl fmt::Display for ExpirationDate {
492    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493        self.0.fmt(f)
494    }
495}
496
497#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
498pub enum OptionType {
499    Call,
500    Put,
501}
502
503impl Default for OptionType {
504    fn default() -> Self {
505        OptionType::Call
506    }
507}
508
509#[derive(Clone, Debug, Eq, PartialEq)]
510pub struct StrategyBreakevens {
511    // breakevens sorted with ascending price
512    pub breakevens: Vec<Breakeven>,
513}
514
515impl StrategyBreakevens {
516    pub fn min(&self) -> Option<&Breakeven> {
517        match (self.breakevens.first(), self.breakevens.len()) {
518            (Some(breakeven), 1) => {
519                if breakeven.is_ascending {
520                    Some(breakeven)
521                } else {
522                    None
523                }
524            }
525            (breakeven, _) => breakeven,
526        }
527    }
528
529    pub fn max(&self) -> Option<&Breakeven> {
530        match (self.breakevens.last(), self.breakevens.len()) {
531            (Some(breakeven), 1) => {
532                if !breakeven.is_ascending {
533                    Some(breakeven)
534                } else {
535                    None
536                }
537            }
538            (breakeven, _) => breakeven,
539        }
540    }
541}
542
543#[derive(Clone, Debug, Eq, PartialEq)]
544pub struct Breakeven {
545    pub price: Rational64,
546    // whether the profit is increasing with increasing price
547    pub is_ascending: bool,
548}
549
550// options should be sorted by strike price
551pub fn calculate_breakevens_for_strategy(positions: &[Position]) -> StrategyBreakevens {
552    if positions.is_empty() {
553        return StrategyBreakevens { breakevens: vec![] };
554    }
555
556    let max_strike_price = positions
557        .iter()
558        .map(|position| position.equivalent_strike_price())
559        .max()
560        .unwrap();
561
562    let profit_at_price = |price| {
563        let profit: Rational64 = positions
564            .iter()
565            .map(|position| position.profit_at_expiry(price))
566            .sum();
567        profit
568    };
569
570    // arbitrary scale factor that once applied to the max strike price should exceed the upper breakeven
571    const MARGIN_SCALE_FACTOR: i64 = 1000;
572    let price_range = (
573        Rational64::zero(),
574        // increment by 1 to handle strike price of zero
575        (max_strike_price + 1) * MARGIN_SCALE_FACTOR,
576    );
577
578    let mut prev_price = price_range.0;
579    let mut prev_profit = profit_at_price(prev_price);
580
581    let mut breakevens = vec![];
582
583    for strike_price in positions
584        .iter()
585        .map(|position| position.equivalent_strike_price())
586        .chain(std::iter::once(price_range.1))
587    {
588        if strike_price == prev_price {
589            continue;
590        }
591
592        assert!(
593            strike_price > prev_price,
594            "Options should be sorted by strike price"
595        );
596
597        let profit = profit_at_price(strike_price);
598        if profit.is_negative() != prev_profit.is_negative() {
599            let x = strike_price - prev_price;
600            let y = profit - prev_profit;
601
602            let dy = -prev_profit / y;
603            breakevens.push(Breakeven {
604                price: prev_price + x * dy,
605                is_ascending: prev_profit.is_negative(),
606            });
607        }
608
609        prev_price = strike_price;
610        prev_profit = profit;
611    }
612
613    StrategyBreakevens { breakevens }
614}
615
616#[derive(Clone, Debug, Eq, PartialEq)]
617pub struct StrategyProfitBounds {
618    pub max_loss: Option<ProfitBound>,
619    pub max_profit: Option<ProfitBound>,
620}
621
622impl StrategyProfitBounds {
623    pub fn to_percentage_of_max_profit(&self, profit: Rational64) -> Option<f64> {
624        self.max_profit
625            .as_ref()
626            .and_then(|b| b.finite_value())
627            .map(|value| {
628                debug_assert!(value.is_positive());
629                (profit / value.abs()).to_f64().unwrap()
630            })
631    }
632}
633
634#[derive(Clone, Debug, Eq, PartialEq)]
635pub enum ProfitBound {
636    Infinite,
637    Finite {
638        value: Rational64,
639        price: Rational64,
640    },
641}
642
643impl ProfitBound {
644    pub fn finite_value(&self) -> Option<Rational64> {
645        match self {
646            ProfitBound::Finite { value, .. } => Some(*value),
647            _ => None,
648        }
649    }
650}
651
652pub fn calculate_profit_bounds_for_strategy(positions: &[Position]) -> StrategyProfitBounds {
653    if positions.is_empty() {
654        return StrategyProfitBounds {
655            max_loss: None,
656            max_profit: None,
657        };
658    }
659
660    let mut min_gradient = Rational64::zero();
661    let mut max_gradient = Rational64::zero();
662    for position in positions {
663        let gradient_delta = position.quantity() * position.equivalent_lot_size();
664        match (position.equivalent_option_type(), position.is_long()) {
665            (OptionType::Call, true) => {
666                max_gradient += gradient_delta;
667            }
668            (OptionType::Call, false) => {
669                max_gradient -= gradient_delta;
670            }
671            (OptionType::Put, true) => {
672                min_gradient += gradient_delta;
673            }
674            (OptionType::Put, false) => {
675                min_gradient -= gradient_delta;
676            }
677        }
678    }
679
680    let max_loss_at_strike = {
681        let mut max_loss = Rational64::from_integer(i64::MAX);
682        let mut max_loss_price = Rational64::zero();
683        for position in positions {
684            let price = position.equivalent_strike_price();
685            let profit_at_price = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
686            if profit_at_price < max_loss {
687                max_loss = profit_at_price;
688                max_loss_price = price;
689            }
690        }
691        ProfitBound::Finite {
692            value: max_loss,
693            price: max_loss_price,
694        }
695    };
696
697    let max_profit_at_strike = {
698        let mut max_profit = Rational64::from_integer(i64::MIN);
699        let mut max_profit_price = Rational64::zero();
700        for position in positions {
701            let price = position.equivalent_strike_price();
702            let profit_at_price = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
703            if profit_at_price > max_profit {
704                max_profit = profit_at_price;
705                max_profit_price = price;
706            }
707        }
708        ProfitBound::Finite {
709            value: max_profit,
710            price: max_profit_price,
711        }
712    };
713
714    let max_loss = if max_gradient.is_negative() {
715        ProfitBound::Infinite
716    } else if min_gradient.is_negative() {
717        let price = Rational64::zero();
718        let profit_at_zero = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
719
720        // profit at zero may not necessarily be the extreme
721        if max_loss_at_strike.finite_value().unwrap() <= profit_at_zero {
722            max_loss_at_strike
723        } else {
724            ProfitBound::Finite {
725                value: profit_at_zero,
726                price,
727            }
728        }
729    } else {
730        max_loss_at_strike
731    };
732
733    let max_profit = if max_gradient.is_positive() {
734        ProfitBound::Infinite
735    } else if min_gradient.is_positive() {
736        let price = Rational64::zero();
737        let profit_at_zero = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
738
739        // profit at zero may not necessarily be the extreme
740        if max_profit_at_strike.finite_value().unwrap() >= profit_at_zero {
741            max_profit_at_strike
742        } else {
743            ProfitBound::Finite {
744                value: profit_at_zero,
745                price,
746            }
747        }
748    } else {
749        max_profit_at_strike
750    };
751
752    StrategyProfitBounds {
753        max_loss: Some(max_loss).filter(|b| {
754            let finite = b.finite_value();
755            finite.is_none() || finite.filter(|v| v.is_negative()).is_some()
756        }),
757        max_profit: Some(max_profit).filter(|b| {
758            let finite = b.finite_value();
759            finite.is_none() || finite.filter(|v| v.is_positive()).is_some()
760        }),
761    }
762}
763
764pub trait ExpirationImpliedVolatilityProvider {
765    fn find_iv_for_expiration_date(&self, date: ExpirationDate) -> Option<f64>;
766}
767
768pub fn calculate_pop_for_breakevens(
769    breakevens: &StrategyBreakevens,
770    profit_bounds: &StrategyProfitBounds,
771    underlying_price: Rational64,
772    iv_provider: &impl ExpirationImpliedVolatilityProvider,
773    expiration_date: ExpirationDate,
774    now: Option<fn() -> DateTime<Utc>>,
775) -> Option<i32> {
776    if breakevens.min().is_none() && breakevens.max().is_none() {
777        if profit_bounds.max_loss.is_none() {
778            return Some(100);
779        } else {
780            debug_assert!(profit_bounds.max_profit.is_none());
781            return Some(0);
782        }
783    }
784
785    let mut pop = if breakevens.breakevens.first().unwrap().is_ascending {
786        0.0
787    } else {
788        1.0
789    };
790
791    for Breakeven {
792        price,
793        is_ascending,
794    } in &breakevens.breakevens
795    {
796        if *is_ascending {
797            pop += calculate_probability_of_expiring_gt_price(
798                *price,
799                underlying_price,
800                iv_provider,
801                expiration_date,
802                now,
803            )?;
804        } else {
805            pop -= calculate_probability_of_expiring_gt_price(
806                *price,
807                underlying_price,
808                iv_provider,
809                expiration_date,
810                now,
811            )?;
812        }
813    }
814
815    Some((pop * 100.0).round() as i32)
816}
817
818fn calculate_probability_of_expiring_gt_price(
819    price: Rational64,
820    underlying_price: Rational64,
821    iv_provider: &impl ExpirationImpliedVolatilityProvider,
822    expiration_date: ExpirationDate,
823    now: Option<fn() -> DateTime<Utc>>,
824) -> Option<f64> {
825    let stock_price = underlying_price.to_f64()?;
826    let expiration_implied_volatility = iv_provider.find_iv_for_expiration_date(expiration_date)?;
827
828    let num_minutes: u32 = expiration_date
829        .time_to_expiration(now)
830        .num_minutes()
831        .try_into()
832        .ok()?;
833    let year_minutes: u32 = Duration::days(365).num_minutes().try_into().ok()?;
834    let time = f64::from(num_minutes) / f64::from(year_minutes);
835    let vol = expiration_implied_volatility * time.sqrt();
836
837    // https://www.ltnielsen.com/wp-content/uploads/Understanding.pdf
838    let interest_rate = 0.05;
839    let d2 =
840        ((interest_rate - 0.5 * vol * vol) * time - (price.to_f64()? / stock_price).ln()) / vol;
841
842    use statrs::distribution::{Normal, Univariate};
843    let prob = Normal::new(0.0, 1.0).unwrap().cdf(d2);
844
845    Some(prob)
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    use chrono::{NaiveDate, TimeZone};
853    use num_traits::One;
854
855    #[test]
856    fn test_decimal_from_str() {
857        assert_eq!(
858            Decimal::from_str("0.3").unwrap(),
859            Decimal(Rational64::new(3, 10))
860        );
861        assert_eq!(
862            Decimal::from_str("-0.3").unwrap(),
863            Decimal(Rational64::new(-3, 10))
864        );
865        assert_eq!(
866            Decimal::from_str("9.12").unwrap(),
867            Decimal(Rational64::new(912, 100))
868        );
869        assert_eq!(
870            Decimal::from_str("-9.12").unwrap(),
871            Decimal(Rational64::new(-912, 100))
872        );
873        assert_eq!(
874            Decimal::from_str("23.012").unwrap(),
875            Decimal(Rational64::new(23012, 1000))
876        );
877        assert_eq!(
878            Decimal::from_str("1.0001").unwrap(),
879            Decimal(Rational64::new(10001, 10000))
880        );
881        assert_eq!(
882            Decimal::from_str("12,345.4321").unwrap(),
883            Decimal(Rational64::new(123454321, 10000))
884        );
885        assert_eq!(
886            Decimal::from_str("+2.1").unwrap(),
887            Decimal(Rational64::new(21, 10))
888        );
889        assert_eq!(
890            Decimal::from_str("2.").unwrap(),
891            Decimal(Rational64::new(2, 1))
892        );
893        assert_eq!(
894            Decimal::from_str("2").unwrap(),
895            Decimal(Rational64::new(2, 1))
896        );
897    }
898
899    #[test]
900    fn test_decimal_to_string() {
901        assert_eq!(
902            Decimal(Rational64::new(3, 10)).to_string(),
903            "0.3".to_string()
904        );
905        assert_eq!(
906            Decimal(Rational64::new(-3, 10)).to_string(),
907            "-0.3".to_string()
908        );
909        assert_eq!(
910            Decimal(Rational64::new(912, 100)).to_string(),
911            "9.12".to_string()
912        );
913        assert_eq!(
914            Decimal(Rational64::new(-912, 100)).to_string(),
915            "-9.12".to_string()
916        );
917        assert_eq!(
918            Decimal(Rational64::new(23012, 1000)).to_string(),
919            "23.012".to_string()
920        );
921        assert_eq!(
922            Decimal(Rational64::new(10001, 10000)).to_string(),
923            "1.0001".to_string()
924        );
925        assert_eq!(
926            Decimal(Rational64::new(123454321, 10000)).to_string(),
927            "12345.4321".to_string()
928        );
929        assert_eq!(
930            Decimal(Rational64::new(21, 10)).to_string(),
931            "2.1".to_string()
932        );
933        assert_eq!(Decimal(Rational64::new(2, 1)).to_string(), "2".to_string());
934    }
935
936    #[test]
937    fn test_decimal_to_str() {
938        assert_eq!(Decimal(Rational64::new(3, 10)).to_string(), "0.3",);
939        assert_eq!(Decimal(Rational64::new(-3, 10)).to_string(), "-0.3",);
940        assert_eq!(Decimal(Rational64::new(912, 100)).to_string(), "9.12",);
941        assert_eq!(Decimal(Rational64::new(-912, 100)).to_string(), "-9.12",);
942        assert_eq!(Decimal(Rational64::new(23012, 1000)).to_string(), "23.012",);
943        assert_eq!(Decimal(Rational64::new(10001, 10000)).to_string(), "1.0001",);
944    }
945
946    #[test]
947    fn test_short_call_profit_at_expiry() {
948        let option = OptionsPosition::mock(OptionType::Call, 100, 300, 1.into());
949        let underlying_price = Rational64::from_integer(101);
950        let profit = option.profit_at_expiry(underlying_price);
951
952        assert_eq!(profit, Rational64::from_integer(200));
953    }
954
955    #[test]
956    fn test_short_put_profit_at_expiry() {
957        let option = OptionsPosition::mock(OptionType::Put, 100, 300, 1.into());
958        let underlying_price = Rational64::from_integer(99);
959        let profit = option.profit_at_expiry(underlying_price);
960
961        assert_eq!(profit, Rational64::from_integer(200));
962    }
963
964    #[test]
965    fn test_long_put_profit_at_expiry() {
966        let option = OptionsPosition::mock(OptionType::Put, 100, -300, 1.into());
967        let underlying_price = Rational64::from_integer(99);
968        let profit = option.profit_at_expiry(underlying_price);
969
970        assert_eq!(profit, Rational64::from_integer(-200));
971    }
972
973    #[test]
974    fn test_calculate_breakevens_for_short_strangle() {
975        let options = [
976            OptionsPosition::mock(OptionType::Put, 20, 37, 1.into()),
977            OptionsPosition::mock(OptionType::Call, 28, 74, 1.into()),
978        ];
979
980        let breakevens = calculate_breakevens_for_strategy(&options);
981
982        assert_eq!(
983            breakevens,
984            StrategyBreakevens {
985                breakevens: vec![
986                    Breakeven {
987                        price: Rational64::new(1889, 100),
988                        is_ascending: true
989                    },
990                    Breakeven {
991                        price: Rational64::new(2911, 100),
992                        is_ascending: false
993                    }
994                ]
995            }
996        );
997    }
998
999    #[test]
1000    fn test_calculate_breakevens_for_short_call_ratio_spread() {
1001        let options = [
1002            OptionsPosition::mock(OptionType::Call, 15, -305, 1.into()),
1003            OptionsPosition::mock(OptionType::Call, 20, 217, 2.into()),
1004        ];
1005
1006        let breakevens = calculate_breakevens_for_strategy(&options);
1007
1008        assert_eq!(
1009            breakevens,
1010            StrategyBreakevens {
1011                breakevens: vec![Breakeven {
1012                    price: Rational64::new(2629, 100),
1013                    is_ascending: false
1014                }]
1015            }
1016        );
1017    }
1018
1019    #[test]
1020    fn test_calculate_profit_for_long_call() {
1021        let options = [OptionsPosition::mock(OptionType::Call, 20, -37, 1.into())];
1022
1023        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1024
1025        assert_eq!(
1026            profit_bounds,
1027            StrategyProfitBounds {
1028                max_loss: Some(ProfitBound::Finite {
1029                    value: Rational64::from_integer(-37),
1030                    price: Rational64::from_integer(20)
1031                }),
1032                max_profit: Some(ProfitBound::Infinite)
1033            }
1034        );
1035    }
1036
1037    #[test]
1038    fn test_calculate_profit_for_long_put() {
1039        let options = [OptionsPosition::mock(OptionType::Put, 20, -37, 1.into())];
1040
1041        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1042
1043        assert_eq!(
1044            profit_bounds,
1045            StrategyProfitBounds {
1046                max_loss: Some(ProfitBound::Finite {
1047                    value: Rational64::from_integer(-37),
1048                    price: Rational64::from_integer(20)
1049                }),
1050                max_profit: Some(ProfitBound::Finite {
1051                    value: Rational64::from_integer(2000 - 37),
1052                    price: Rational64::zero()
1053                })
1054            }
1055        );
1056    }
1057
1058    #[test]
1059    fn test_calculate_profit_for_short_strangle() {
1060        let options = [
1061            OptionsPosition::mock(OptionType::Put, 20, 37, 1.into()),
1062            OptionsPosition::mock(OptionType::Call, 28, 74, 1.into()),
1063        ];
1064
1065        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1066
1067        assert_eq!(
1068            profit_bounds,
1069            StrategyProfitBounds {
1070                max_loss: Some(ProfitBound::Infinite),
1071                max_profit: Some(ProfitBound::Finite {
1072                    value: Rational64::from_integer(37 + 74),
1073                    price: Rational64::from_integer(20)
1074                })
1075            }
1076        );
1077    }
1078
1079    #[test]
1080    fn test_calculate_profit_for_long_strangle() {
1081        let options = [
1082            OptionsPosition::mock(OptionType::Put, 20, -37, 1.into()),
1083            OptionsPosition::mock(OptionType::Call, 28, -74, 1.into()),
1084        ];
1085
1086        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1087
1088        assert_eq!(
1089            profit_bounds,
1090            StrategyProfitBounds {
1091                max_loss: Some(ProfitBound::Finite {
1092                    value: Rational64::from_integer(-37 - 74),
1093                    price: Rational64::from_integer(20)
1094                }),
1095                max_profit: Some(ProfitBound::Infinite),
1096            }
1097        );
1098    }
1099
1100    #[test]
1101    fn test_calculate_profit_for_short_call_ratio_spread() {
1102        let options = [
1103            OptionsPosition::mock(OptionType::Call, 15, -305, 1.into()),
1104            OptionsPosition::mock(OptionType::Call, 20, 217, 2.into()),
1105        ];
1106
1107        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1108
1109        assert_eq!(
1110            profit_bounds,
1111            StrategyProfitBounds {
1112                max_loss: Some(ProfitBound::Infinite),
1113                max_profit: Some(ProfitBound::Finite {
1114                    value: Rational64::from_integer(-305 + 2 * 217 + 500),
1115                    price: Rational64::from_integer(20)
1116                })
1117            }
1118        );
1119    }
1120
1121    #[test]
1122    fn test_calculate_profit_for_long_put_ratio_spread() {
1123        let options = [
1124            OptionsPosition::mock(OptionType::Put, 20, 305, 1.into()),
1125            OptionsPosition::mock(OptionType::Put, 15, -217, 2.into()),
1126        ];
1127
1128        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1129
1130        let max_loss = 305 - 2 * 217 - 500;
1131        let max_profit = max_loss + 1500;
1132        assert_eq!(
1133            profit_bounds,
1134            StrategyProfitBounds {
1135                max_loss: Some(ProfitBound::Finite {
1136                    value: Rational64::from_integer(max_loss),
1137                    price: Rational64::from_integer(15)
1138                }),
1139                max_profit: Some(ProfitBound::Finite {
1140                    value: Rational64::from_integer(max_profit),
1141                    price: Rational64::from_integer(0)
1142                }),
1143            }
1144        );
1145    }
1146
1147    #[test]
1148    fn test_calculate_profit_for_covered_call() {
1149        let positions = [
1150            SharesPosition::mock(-11, 200.into()),
1151            OptionsPosition::mock(OptionType::Call, 20, 30, 2.into()),
1152        ];
1153
1154        let profit_bounds = calculate_profit_bounds_for_strategy(&positions);
1155
1156        let max_loss = -11 * 200 + 30 * 2;
1157        let max_profit = (20 - 11) * 200 + 30 * 2;
1158        assert_eq!(
1159            profit_bounds,
1160            StrategyProfitBounds {
1161                max_loss: Some(ProfitBound::Finite {
1162                    value: Rational64::from_integer(max_loss),
1163                    price: Rational64::from_integer(0)
1164                }),
1165                max_profit: Some(ProfitBound::Finite {
1166                    value: Rational64::from_integer(max_profit),
1167                    price: Rational64::from_integer(20)
1168                }),
1169            }
1170        );
1171    }
1172
1173    #[test]
1174    fn test_calculate_breakevens_for_shares() {
1175        let positions = [
1176            SharesPosition::mock(-11, 150.into()), // long
1177            SharesPosition::mock(19, 50.into()),   // short
1178        ];
1179        let breakevens = calculate_breakevens_for_strategy(&positions);
1180        assert_eq!(
1181            breakevens,
1182            StrategyBreakevens {
1183                breakevens: vec![Breakeven {
1184                    price: Rational64::from_integer(7),
1185                    is_ascending: true,
1186                }]
1187            }
1188        );
1189    }
1190
1191    #[test]
1192    fn test_calculate_breakevens_for_leap() {
1193        let positions = [OptionsPosition::mock(OptionType::Call, 1, -30, 1.into())];
1194        let breakevens = calculate_breakevens_for_strategy(&positions);
1195        assert_eq!(
1196            breakevens,
1197            StrategyBreakevens {
1198                breakevens: vec![Breakeven {
1199                    price: Rational64::new(13, 10),
1200                    is_ascending: true,
1201                }]
1202            }
1203        );
1204    }
1205
1206    #[test]
1207    fn test_calculate_pop_no_loss() {
1208        struct IVProvider;
1209        impl ExpirationImpliedVolatilityProvider for IVProvider {
1210            fn find_iv_for_expiration_date(&self, _: ExpirationDate) -> Option<f64> {
1211                None
1212            }
1213        }
1214
1215        let pop = calculate_pop_for_breakevens(
1216            &StrategyBreakevens { breakevens: vec![] },
1217            &StrategyProfitBounds {
1218                max_loss: None,
1219                max_profit: Some(ProfitBound::Infinite),
1220            },
1221            Rational64::one(),
1222            &IVProvider,
1223            ExpirationDate(NaiveDate::from_ymd(2020, 10, 16)),
1224            None,
1225        );
1226        assert_eq!(pop, Some(100));
1227    }
1228
1229    #[test]
1230    fn test_calculate_pop_multiple_breakevens() {
1231        let option_positions = [
1232            OptionsPosition::mock(OptionType::Put, 20, -55, 2.into()),
1233            OptionsPosition::mock(OptionType::Put, 30, 277, 1.into()),
1234            OptionsPosition::mock(OptionType::Call, 60, -823, 1.into()),
1235            OptionsPosition::mock(OptionType::Call, 85, 456, 2.into()),
1236        ];
1237
1238        let breakevens = calculate_breakevens_for_strategy(&option_positions);
1239
1240        assert_eq!(
1241            breakevens,
1242            StrategyBreakevens {
1243                breakevens: vec![
1244                    Breakeven {
1245                        price: Rational64::new(314, 25),
1246                        is_ascending: false,
1247                    },
1248                    Breakeven {
1249                        price: Rational64::new(686, 25),
1250                        is_ascending: true,
1251                    },
1252                    Breakeven {
1253                        price: Rational64::new(2814, 25),
1254                        is_ascending: false,
1255                    },
1256                ]
1257            }
1258        );
1259
1260        fn expiration_date() -> ExpirationDate {
1261            ExpirationDate(NaiveDate::from_ymd(2020, 10, 16))
1262        }
1263
1264        struct IVProvider;
1265        impl ExpirationImpliedVolatilityProvider for IVProvider {
1266            fn find_iv_for_expiration_date(&self, date: ExpirationDate) -> Option<f64> {
1267                if date == expiration_date() {
1268                    Some(2.00)
1269                } else {
1270                    None
1271                }
1272            }
1273        }
1274
1275        let profit_bounds = calculate_profit_bounds_for_strategy(&option_positions);
1276
1277        assert_eq!(
1278            profit_bounds,
1279            StrategyProfitBounds {
1280                max_loss: Some(ProfitBound::Infinite),
1281                max_profit: Some(ProfitBound::Finite {
1282                    value: Rational64::from_integer(2756),
1283                    price: Rational64::from_integer(85)
1284                },),
1285            }
1286        );
1287
1288        let pop = calculate_pop_for_breakevens(
1289            &breakevens,
1290            &profit_bounds,
1291            Rational64::new(475, 10),
1292            &IVProvider,
1293            expiration_date(),
1294            Some(|| Utc.ymd(2020, 9, 18).and_hms(1, 1, 1)),
1295        );
1296        assert_eq!(pop, Some(79));
1297    }
1298}