Skip to main content

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        NotNan::new(self.unit_delta()?.into_inner() * self.quantity().to_f64()?).ok()
100    }
101
102    /// The total vega for all option contracts or shares in this position.
103    fn vega(&self) -> Option<NotNan<f64>> {
104        NotNan::new(self.unit_vega()?.into_inner() * self.quantity().to_f64()?).ok()
105    }
106
107    /// The total theta for all option contracts or shares in this position.
108    fn theta(&self) -> Option<NotNan<f64>> {
109        NotNan::new(self.unit_theta()?.into_inner() * self.quantity().to_f64()?).ok()
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 = self.equivalent_lot_size();
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(['-', '+'], "");
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_opt(1, 1, 1).unwrap())
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, Default, Eq, Hash, PartialEq)]
498pub enum OptionType {
499    #[default]
500    Call,
501    Put,
502}
503
504#[derive(Clone, Debug, Eq, PartialEq)]
505pub struct StrategyBreakevens {
506    // breakevens sorted with ascending price
507    pub breakevens: Vec<Breakeven>,
508}
509
510impl StrategyBreakevens {
511    pub fn min(&self) -> Option<&Breakeven> {
512        match (self.breakevens.first(), self.breakevens.len()) {
513            (Some(breakeven), 1) => {
514                if breakeven.is_ascending {
515                    Some(breakeven)
516                } else {
517                    None
518                }
519            }
520            (breakeven, _) => breakeven,
521        }
522    }
523
524    pub fn max(&self) -> Option<&Breakeven> {
525        match (self.breakevens.last(), self.breakevens.len()) {
526            (Some(breakeven), 1) => {
527                if !breakeven.is_ascending {
528                    Some(breakeven)
529                } else {
530                    None
531                }
532            }
533            (breakeven, _) => breakeven,
534        }
535    }
536}
537
538#[derive(Clone, Debug, Eq, PartialEq)]
539pub struct Breakeven {
540    pub price: Rational64,
541    // whether the profit is increasing with increasing price
542    pub is_ascending: bool,
543}
544
545// options should be sorted by strike price
546pub fn calculate_breakevens_for_strategy(positions: &[Position]) -> StrategyBreakevens {
547    if positions.is_empty() {
548        return StrategyBreakevens { breakevens: vec![] };
549    }
550
551    let max_strike_price = positions
552        .iter()
553        .map(|position| position.equivalent_strike_price())
554        .max()
555        .unwrap();
556
557    let profit_at_price = |price| {
558        let profit: Rational64 = positions
559            .iter()
560            .map(|position| position.profit_at_expiry(price))
561            .sum();
562        profit
563    };
564
565    // arbitrary scale factor that once applied to the max strike price should exceed the upper breakeven
566    const MARGIN_SCALE_FACTOR: i64 = 1000;
567    let price_range = (
568        Rational64::zero(),
569        // increment by 1 to handle strike price of zero
570        (max_strike_price + 1) * MARGIN_SCALE_FACTOR,
571    );
572
573    let mut prev_price = price_range.0;
574    let mut prev_profit = profit_at_price(prev_price);
575
576    let mut breakevens = vec![];
577
578    for strike_price in positions
579        .iter()
580        .map(|position| position.equivalent_strike_price())
581        .chain(std::iter::once(price_range.1))
582    {
583        if strike_price == prev_price {
584            continue;
585        }
586
587        assert!(
588            strike_price > prev_price,
589            "Options should be sorted by strike price"
590        );
591
592        let profit = profit_at_price(strike_price);
593        if profit.is_negative() != prev_profit.is_negative() {
594            let x = strike_price - prev_price;
595            let y = profit - prev_profit;
596
597            let dy = -prev_profit / y;
598            breakevens.push(Breakeven {
599                price: prev_price + x * dy,
600                is_ascending: prev_profit.is_negative(),
601            });
602        }
603
604        prev_price = strike_price;
605        prev_profit = profit;
606    }
607
608    StrategyBreakevens { breakevens }
609}
610
611#[derive(Clone, Debug, Eq, PartialEq)]
612pub struct StrategyProfitBounds {
613    pub max_loss: Option<ProfitBound>,
614    pub max_profit: Option<ProfitBound>,
615}
616
617impl StrategyProfitBounds {
618    pub fn to_percentage_of_max_profit(&self, profit: Rational64) -> Option<f64> {
619        self.max_profit
620            .as_ref()
621            .and_then(|b| b.finite_value())
622            .map(|value| {
623                debug_assert!(value.is_positive());
624                (profit / value.abs()).to_f64().unwrap()
625            })
626    }
627}
628
629#[derive(Clone, Debug, Eq, PartialEq)]
630pub enum ProfitBound {
631    Infinite,
632    Finite {
633        value: Rational64,
634        price: Rational64,
635    },
636}
637
638impl ProfitBound {
639    pub fn finite_value(&self) -> Option<Rational64> {
640        match self {
641            ProfitBound::Finite { value, .. } => Some(*value),
642            _ => None,
643        }
644    }
645}
646
647pub fn calculate_profit_bounds_for_strategy(positions: &[Position]) -> StrategyProfitBounds {
648    if positions.is_empty() {
649        return StrategyProfitBounds {
650            max_loss: None,
651            max_profit: None,
652        };
653    }
654
655    let mut min_gradient = Rational64::zero();
656    let mut max_gradient = Rational64::zero();
657    for position in positions {
658        let gradient_delta = position.quantity() * position.equivalent_lot_size();
659        match (position.equivalent_option_type(), position.is_long()) {
660            (OptionType::Call, true) => {
661                max_gradient += gradient_delta;
662            }
663            (OptionType::Call, false) => {
664                max_gradient -= gradient_delta;
665            }
666            (OptionType::Put, true) => {
667                min_gradient += gradient_delta;
668            }
669            (OptionType::Put, false) => {
670                min_gradient -= gradient_delta;
671            }
672        }
673    }
674
675    let max_loss_at_strike = {
676        let mut max_loss = Rational64::from_integer(i64::MAX);
677        let mut max_loss_price = Rational64::zero();
678        for position in positions {
679            let price = position.equivalent_strike_price();
680            let profit_at_price = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
681            if profit_at_price < max_loss {
682                max_loss = profit_at_price;
683                max_loss_price = price;
684            }
685        }
686        ProfitBound::Finite {
687            value: max_loss,
688            price: max_loss_price,
689        }
690    };
691
692    let max_profit_at_strike = {
693        let mut max_profit = Rational64::from_integer(i64::MIN);
694        let mut max_profit_price = Rational64::zero();
695        for position in positions {
696            let price = position.equivalent_strike_price();
697            let profit_at_price = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
698            if profit_at_price > max_profit {
699                max_profit = profit_at_price;
700                max_profit_price = price;
701            }
702        }
703        ProfitBound::Finite {
704            value: max_profit,
705            price: max_profit_price,
706        }
707    };
708
709    let max_loss = if max_gradient.is_negative() {
710        ProfitBound::Infinite
711    } else if min_gradient.is_negative() {
712        let price = Rational64::zero();
713        let profit_at_zero = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
714
715        // profit at zero may not necessarily be the extreme
716        if max_loss_at_strike.finite_value().unwrap() <= profit_at_zero {
717            max_loss_at_strike
718        } else {
719            ProfitBound::Finite {
720                value: profit_at_zero,
721                price,
722            }
723        }
724    } else {
725        max_loss_at_strike
726    };
727
728    let max_profit = if max_gradient.is_positive() {
729        ProfitBound::Infinite
730    } else if min_gradient.is_positive() {
731        let price = Rational64::zero();
732        let profit_at_zero = positions.iter().map(|o| o.profit_at_expiry(price)).sum();
733
734        // profit at zero may not necessarily be the extreme
735        if max_profit_at_strike.finite_value().unwrap() >= profit_at_zero {
736            max_profit_at_strike
737        } else {
738            ProfitBound::Finite {
739                value: profit_at_zero,
740                price,
741            }
742        }
743    } else {
744        max_profit_at_strike
745    };
746
747    StrategyProfitBounds {
748        max_loss: Some(max_loss).filter(|b| {
749            let finite = b.finite_value();
750            finite.is_none() || finite.filter(|v| v.is_negative()).is_some()
751        }),
752        max_profit: Some(max_profit).filter(|b| {
753            let finite = b.finite_value();
754            finite.is_none() || finite.filter(|v| v.is_positive()).is_some()
755        }),
756    }
757}
758
759pub trait ExpirationImpliedVolatilityProvider {
760    fn find_iv_for_expiration_date(&self, date: ExpirationDate) -> Option<f64>;
761}
762
763pub fn calculate_pop_for_breakevens(
764    breakevens: &StrategyBreakevens,
765    profit_bounds: &StrategyProfitBounds,
766    underlying_price: Rational64,
767    iv_provider: &impl ExpirationImpliedVolatilityProvider,
768    expiration_date: ExpirationDate,
769    now: Option<fn() -> DateTime<Utc>>,
770) -> Option<i32> {
771    if breakevens.min().is_none() && breakevens.max().is_none() {
772        if profit_bounds.max_loss.is_none() {
773            return Some(100);
774        } else {
775            debug_assert!(profit_bounds.max_profit.is_none());
776            return Some(0);
777        }
778    }
779
780    let mut pop = if breakevens.breakevens.first().unwrap().is_ascending {
781        0.0
782    } else {
783        1.0
784    };
785
786    for Breakeven {
787        price,
788        is_ascending,
789    } in &breakevens.breakevens
790    {
791        if *is_ascending {
792            pop += calculate_probability_of_expiring_gt_price(
793                *price,
794                underlying_price,
795                iv_provider,
796                expiration_date,
797                now,
798            )?;
799        } else {
800            pop -= calculate_probability_of_expiring_gt_price(
801                *price,
802                underlying_price,
803                iv_provider,
804                expiration_date,
805                now,
806            )?;
807        }
808    }
809
810    Some((pop * 100.0).round() as i32)
811}
812
813fn calculate_probability_of_expiring_gt_price(
814    price: Rational64,
815    underlying_price: Rational64,
816    iv_provider: &impl ExpirationImpliedVolatilityProvider,
817    expiration_date: ExpirationDate,
818    now: Option<fn() -> DateTime<Utc>>,
819) -> Option<f64> {
820    let stock_price = underlying_price.to_f64()?;
821    let strike_price = price.to_f64()?;
822    let sigma = iv_provider.find_iv_for_expiration_date(expiration_date)?;
823    if [stock_price, strike_price, sigma]
824        .iter()
825        .any(|&value| !value.is_finite() || value <= 0.0)
826    {
827        return None;
828    }
829
830    let num_minutes: u32 = expiration_date
831        .time_to_expiration(now)
832        .num_minutes()
833        .try_into()
834        .ok()?;
835    let year_minutes: u32 = Duration::days(365).num_minutes().try_into().ok()?;
836    let time = f64::from(num_minutes) / f64::from(year_minutes);
837    if !time.is_finite() || time <= 0.0 {
838        return None;
839    }
840
841    // https://www.ltnielsen.com/wp-content/uploads/Understanding.pdf
842    let interest_rate = 0.05;
843    let d2 = ((stock_price / strike_price).ln() + (interest_rate - 0.5 * sigma * sigma) * time)
844        / (sigma * time.sqrt());
845
846    use statrs::distribution::{ContinuousCDF, Normal};
847    let prob = Normal::new(0.0, 1.0).unwrap().cdf(d2);
848
849    Some(prob)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    use chrono::{NaiveDate, TimeZone};
857    use num_traits::One;
858
859    #[test]
860    fn test_decimal_from_str() {
861        assert_eq!(
862            Decimal::from_str("0.3").unwrap(),
863            Decimal(Rational64::new(3, 10))
864        );
865        assert_eq!(
866            Decimal::from_str("-0.3").unwrap(),
867            Decimal(Rational64::new(-3, 10))
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("-9.12").unwrap(),
875            Decimal(Rational64::new(-912, 100))
876        );
877        assert_eq!(
878            Decimal::from_str("23.012").unwrap(),
879            Decimal(Rational64::new(23012, 1000))
880        );
881        assert_eq!(
882            Decimal::from_str("1.0001").unwrap(),
883            Decimal(Rational64::new(10001, 10000))
884        );
885        assert_eq!(
886            Decimal::from_str("12,345.4321").unwrap(),
887            Decimal(Rational64::new(123454321, 10000))
888        );
889        assert_eq!(
890            Decimal::from_str("+2.1").unwrap(),
891            Decimal(Rational64::new(21, 10))
892        );
893        assert_eq!(
894            Decimal::from_str("2.").unwrap(),
895            Decimal(Rational64::new(2, 1))
896        );
897        assert_eq!(
898            Decimal::from_str("2").unwrap(),
899            Decimal(Rational64::new(2, 1))
900        );
901    }
902
903    #[test]
904    fn test_decimal_to_string() {
905        assert_eq!(
906            Decimal(Rational64::new(3, 10)).to_string(),
907            "0.3".to_string()
908        );
909        assert_eq!(
910            Decimal(Rational64::new(-3, 10)).to_string(),
911            "-0.3".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(-912, 100)).to_string(),
919            "-9.12".to_string()
920        );
921        assert_eq!(
922            Decimal(Rational64::new(23012, 1000)).to_string(),
923            "23.012".to_string()
924        );
925        assert_eq!(
926            Decimal(Rational64::new(10001, 10000)).to_string(),
927            "1.0001".to_string()
928        );
929        assert_eq!(
930            Decimal(Rational64::new(123454321, 10000)).to_string(),
931            "12345.4321".to_string()
932        );
933        assert_eq!(
934            Decimal(Rational64::new(21, 10)).to_string(),
935            "2.1".to_string()
936        );
937        assert_eq!(Decimal(Rational64::new(2, 1)).to_string(), "2".to_string());
938    }
939
940    #[test]
941    fn test_decimal_to_str() {
942        assert_eq!(Decimal(Rational64::new(3, 10)).to_string(), "0.3",);
943        assert_eq!(Decimal(Rational64::new(-3, 10)).to_string(), "-0.3",);
944        assert_eq!(Decimal(Rational64::new(912, 100)).to_string(), "9.12",);
945        assert_eq!(Decimal(Rational64::new(-912, 100)).to_string(), "-9.12",);
946        assert_eq!(Decimal(Rational64::new(23012, 1000)).to_string(), "23.012",);
947        assert_eq!(Decimal(Rational64::new(10001, 10000)).to_string(), "1.0001",);
948    }
949
950    #[test]
951    fn test_short_call_profit_at_expiry() {
952        let option = OptionsPosition::mock(OptionType::Call, 100, 300, 1.into());
953        let underlying_price = Rational64::from_integer(101);
954        let profit = option.profit_at_expiry(underlying_price);
955
956        assert_eq!(profit, Rational64::from_integer(200));
957    }
958
959    #[test]
960    fn test_short_put_profit_at_expiry() {
961        let option = OptionsPosition::mock(OptionType::Put, 100, 300, 1.into());
962        let underlying_price = Rational64::from_integer(99);
963        let profit = option.profit_at_expiry(underlying_price);
964
965        assert_eq!(profit, Rational64::from_integer(200));
966    }
967
968    #[test]
969    fn test_long_put_profit_at_expiry() {
970        let option = OptionsPosition::mock(OptionType::Put, 100, -300, 1.into());
971        let underlying_price = Rational64::from_integer(99);
972        let profit = option.profit_at_expiry(underlying_price);
973
974        assert_eq!(profit, Rational64::from_integer(-200));
975    }
976
977    #[test]
978    fn test_calculate_breakevens_for_short_strangle() {
979        let options = [
980            OptionsPosition::mock(OptionType::Put, 20, 37, 1.into()),
981            OptionsPosition::mock(OptionType::Call, 28, 74, 1.into()),
982        ];
983
984        let breakevens = calculate_breakevens_for_strategy(&options);
985
986        assert_eq!(
987            breakevens,
988            StrategyBreakevens {
989                breakevens: vec![
990                    Breakeven {
991                        price: Rational64::new(1889, 100),
992                        is_ascending: true
993                    },
994                    Breakeven {
995                        price: Rational64::new(2911, 100),
996                        is_ascending: false
997                    }
998                ]
999            }
1000        );
1001    }
1002
1003    #[test]
1004    fn test_calculate_breakevens_for_short_call_ratio_spread() {
1005        let options = [
1006            OptionsPosition::mock(OptionType::Call, 15, -305, 1.into()),
1007            OptionsPosition::mock(OptionType::Call, 20, 217, 2.into()),
1008        ];
1009
1010        let breakevens = calculate_breakevens_for_strategy(&options);
1011
1012        assert_eq!(
1013            breakevens,
1014            StrategyBreakevens {
1015                breakevens: vec![Breakeven {
1016                    price: Rational64::new(2629, 100),
1017                    is_ascending: false
1018                }]
1019            }
1020        );
1021    }
1022
1023    #[test]
1024    fn test_calculate_profit_for_long_call() {
1025        let options = [OptionsPosition::mock(OptionType::Call, 20, -37, 1.into())];
1026
1027        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1028
1029        assert_eq!(
1030            profit_bounds,
1031            StrategyProfitBounds {
1032                max_loss: Some(ProfitBound::Finite {
1033                    value: Rational64::from_integer(-37),
1034                    price: Rational64::from_integer(20)
1035                }),
1036                max_profit: Some(ProfitBound::Infinite)
1037            }
1038        );
1039    }
1040
1041    #[test]
1042    fn test_calculate_profit_for_long_put() {
1043        let options = [OptionsPosition::mock(OptionType::Put, 20, -37, 1.into())];
1044
1045        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1046
1047        assert_eq!(
1048            profit_bounds,
1049            StrategyProfitBounds {
1050                max_loss: Some(ProfitBound::Finite {
1051                    value: Rational64::from_integer(-37),
1052                    price: Rational64::from_integer(20)
1053                }),
1054                max_profit: Some(ProfitBound::Finite {
1055                    value: Rational64::from_integer(2000 - 37),
1056                    price: Rational64::zero()
1057                })
1058            }
1059        );
1060    }
1061
1062    #[test]
1063    fn test_calculate_profit_for_short_strangle() {
1064        let options = [
1065            OptionsPosition::mock(OptionType::Put, 20, 37, 1.into()),
1066            OptionsPosition::mock(OptionType::Call, 28, 74, 1.into()),
1067        ];
1068
1069        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1070
1071        assert_eq!(
1072            profit_bounds,
1073            StrategyProfitBounds {
1074                max_loss: Some(ProfitBound::Infinite),
1075                max_profit: Some(ProfitBound::Finite {
1076                    value: Rational64::from_integer(37 + 74),
1077                    price: Rational64::from_integer(20)
1078                })
1079            }
1080        );
1081    }
1082
1083    #[test]
1084    fn test_calculate_profit_for_long_strangle() {
1085        let options = [
1086            OptionsPosition::mock(OptionType::Put, 20, -37, 1.into()),
1087            OptionsPosition::mock(OptionType::Call, 28, -74, 1.into()),
1088        ];
1089
1090        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1091
1092        assert_eq!(
1093            profit_bounds,
1094            StrategyProfitBounds {
1095                max_loss: Some(ProfitBound::Finite {
1096                    value: Rational64::from_integer(-37 - 74),
1097                    price: Rational64::from_integer(20)
1098                }),
1099                max_profit: Some(ProfitBound::Infinite),
1100            }
1101        );
1102    }
1103
1104    #[test]
1105    fn test_calculate_profit_for_short_call_ratio_spread() {
1106        let options = [
1107            OptionsPosition::mock(OptionType::Call, 15, -305, 1.into()),
1108            OptionsPosition::mock(OptionType::Call, 20, 217, 2.into()),
1109        ];
1110
1111        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1112
1113        assert_eq!(
1114            profit_bounds,
1115            StrategyProfitBounds {
1116                max_loss: Some(ProfitBound::Infinite),
1117                max_profit: Some(ProfitBound::Finite {
1118                    value: Rational64::from_integer(-305 + 2 * 217 + 500),
1119                    price: Rational64::from_integer(20)
1120                })
1121            }
1122        );
1123    }
1124
1125    #[test]
1126    fn test_calculate_profit_for_long_put_ratio_spread() {
1127        let options = [
1128            OptionsPosition::mock(OptionType::Put, 20, 305, 1.into()),
1129            OptionsPosition::mock(OptionType::Put, 15, -217, 2.into()),
1130        ];
1131
1132        let profit_bounds = calculate_profit_bounds_for_strategy(&options);
1133
1134        let max_loss = 305 - 2 * 217 - 500;
1135        let max_profit = max_loss + 1500;
1136        assert_eq!(
1137            profit_bounds,
1138            StrategyProfitBounds {
1139                max_loss: Some(ProfitBound::Finite {
1140                    value: Rational64::from_integer(max_loss),
1141                    price: Rational64::from_integer(15)
1142                }),
1143                max_profit: Some(ProfitBound::Finite {
1144                    value: Rational64::from_integer(max_profit),
1145                    price: Rational64::from_integer(0)
1146                }),
1147            }
1148        );
1149    }
1150
1151    #[test]
1152    fn test_calculate_profit_for_covered_call() {
1153        let positions = [
1154            SharesPosition::mock(-11, 200.into()),
1155            OptionsPosition::mock(OptionType::Call, 20, 30, 2.into()),
1156        ];
1157
1158        let profit_bounds = calculate_profit_bounds_for_strategy(&positions);
1159
1160        let max_loss = -11 * 200 + 30 * 2;
1161        let max_profit = (20 - 11) * 200 + 30 * 2;
1162        assert_eq!(
1163            profit_bounds,
1164            StrategyProfitBounds {
1165                max_loss: Some(ProfitBound::Finite {
1166                    value: Rational64::from_integer(max_loss),
1167                    price: Rational64::from_integer(0)
1168                }),
1169                max_profit: Some(ProfitBound::Finite {
1170                    value: Rational64::from_integer(max_profit),
1171                    price: Rational64::from_integer(20)
1172                }),
1173            }
1174        );
1175    }
1176
1177    #[test]
1178    fn test_calculate_breakevens_for_shares() {
1179        let positions = [
1180            SharesPosition::mock(-11, 150.into()), // long
1181            SharesPosition::mock(19, 50.into()),   // short
1182        ];
1183        let breakevens = calculate_breakevens_for_strategy(&positions);
1184        assert_eq!(
1185            breakevens,
1186            StrategyBreakevens {
1187                breakevens: vec![Breakeven {
1188                    price: Rational64::from_integer(7),
1189                    is_ascending: true,
1190                }]
1191            }
1192        );
1193    }
1194
1195    #[test]
1196    fn test_calculate_breakevens_for_leap() {
1197        let positions = [OptionsPosition::mock(OptionType::Call, 1, -30, 1.into())];
1198        let breakevens = calculate_breakevens_for_strategy(&positions);
1199        assert_eq!(
1200            breakevens,
1201            StrategyBreakevens {
1202                breakevens: vec![Breakeven {
1203                    price: Rational64::new(13, 10),
1204                    is_ascending: true,
1205                }]
1206            }
1207        );
1208    }
1209
1210    #[test]
1211    fn test_calculate_pop_no_loss() {
1212        struct IVProvider;
1213        impl ExpirationImpliedVolatilityProvider for IVProvider {
1214            fn find_iv_for_expiration_date(&self, _: ExpirationDate) -> Option<f64> {
1215                None
1216            }
1217        }
1218
1219        let pop = calculate_pop_for_breakevens(
1220            &StrategyBreakevens { breakevens: vec![] },
1221            &StrategyProfitBounds {
1222                max_loss: None,
1223                max_profit: Some(ProfitBound::Infinite),
1224            },
1225            Rational64::one(),
1226            &IVProvider,
1227            ExpirationDate(NaiveDate::from_ymd_opt(2020, 10, 16).unwrap()),
1228            None,
1229        );
1230        assert_eq!(pop, Some(100));
1231    }
1232
1233    fn pop_test_now() -> DateTime<Utc> {
1234        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
1235    }
1236
1237    struct FixedIv(f64);
1238
1239    impl ExpirationImpliedVolatilityProvider for FixedIv {
1240        fn find_iv_for_expiration_date(&self, _: ExpirationDate) -> Option<f64> {
1241            Some(self.0)
1242        }
1243    }
1244
1245    #[test]
1246    fn test_probability_of_expiring_above_price_matches_black_scholes() {
1247        // strike, stock, days, IV, independently calculated probability
1248        let cases = [
1249            (100, 100, 30, 0.20, 0.517_150_693_219_389_9),
1250            (90, 100, 180, 0.20, 0.803_863_968_936_066_6),
1251            (120, 100, 365, 0.40, 0.297_777_341_382_160_4),
1252        ];
1253
1254        for (strike, stock, days, iv, expected) in cases {
1255            let expiration = ExpirationDate(pop_test_now().date_naive() + Duration::days(days));
1256            let actual = calculate_probability_of_expiring_gt_price(
1257                strike.into(),
1258                stock.into(),
1259                &FixedIv(iv),
1260                expiration,
1261                Some(pop_test_now),
1262            )
1263            .unwrap();
1264            assert!((actual - expected).abs() < 1e-9);
1265        }
1266    }
1267
1268    #[test]
1269    fn test_probability_rejects_invalid_inputs() {
1270        let future = ExpirationDate(pop_test_now().date_naive() + Duration::days(30));
1271        let today = ExpirationDate(pop_test_now().date_naive());
1272        let probability = |strike, stock, iv, expiration| {
1273            calculate_probability_of_expiring_gt_price(
1274                Rational64::from_integer(strike),
1275                Rational64::from_integer(stock),
1276                &FixedIv(iv),
1277                expiration,
1278                Some(pop_test_now),
1279            )
1280        };
1281
1282        assert_eq!(probability(0, 100, 0.2, future), None);
1283        assert_eq!(probability(100, 0, 0.2, future), None);
1284        assert_eq!(probability(100, 100, f64::NAN, future), None);
1285        assert_eq!(probability(100, 100, 0.2, today), None);
1286    }
1287
1288    #[test]
1289    fn test_calculate_pop_multiple_breakevens() {
1290        let option_positions = [
1291            OptionsPosition::mock(OptionType::Put, 20, -55, 2.into()),
1292            OptionsPosition::mock(OptionType::Put, 30, 277, 1.into()),
1293            OptionsPosition::mock(OptionType::Call, 60, -823, 1.into()),
1294            OptionsPosition::mock(OptionType::Call, 85, 456, 2.into()),
1295        ];
1296
1297        let breakevens = calculate_breakevens_for_strategy(&option_positions);
1298
1299        assert_eq!(
1300            breakevens,
1301            StrategyBreakevens {
1302                breakevens: vec![
1303                    Breakeven {
1304                        price: Rational64::new(314, 25),
1305                        is_ascending: false,
1306                    },
1307                    Breakeven {
1308                        price: Rational64::new(686, 25),
1309                        is_ascending: true,
1310                    },
1311                    Breakeven {
1312                        price: Rational64::new(2814, 25),
1313                        is_ascending: false,
1314                    },
1315                ]
1316            }
1317        );
1318
1319        fn expiration_date() -> ExpirationDate {
1320            ExpirationDate(NaiveDate::from_ymd_opt(2020, 10, 16).unwrap())
1321        }
1322
1323        struct IVProvider;
1324        impl ExpirationImpliedVolatilityProvider for IVProvider {
1325            fn find_iv_for_expiration_date(&self, date: ExpirationDate) -> Option<f64> {
1326                if date == expiration_date() {
1327                    Some(2.00)
1328                } else {
1329                    None
1330                }
1331            }
1332        }
1333
1334        let profit_bounds = calculate_profit_bounds_for_strategy(&option_positions);
1335
1336        assert_eq!(
1337            profit_bounds,
1338            StrategyProfitBounds {
1339                max_loss: Some(ProfitBound::Infinite),
1340                max_profit: Some(ProfitBound::Finite {
1341                    value: Rational64::from_integer(2756),
1342                    price: Rational64::from_integer(85)
1343                },),
1344            }
1345        );
1346
1347        let pop = calculate_pop_for_breakevens(
1348            &breakevens,
1349            &profit_bounds,
1350            Rational64::new(475, 10),
1351            &IVProvider,
1352            expiration_date(),
1353            Some(|| Utc.with_ymd_and_hms(2020, 9, 18, 1, 1, 1).unwrap()),
1354        );
1355        assert_eq!(pop, Some(75));
1356    }
1357}