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#[enum_dispatch(Position)]
23pub trait GenericPosition {
24 fn symbol(&self) -> &str;
26
27 fn underlying_symbol(&self) -> &str;
30
31 fn is_long(&self) -> bool;
33
34 fn unit_cost(&self) -> Option<Rational64>;
36 fn unit_cost_mut(&mut self) -> &mut Option<Rational64>;
37
38 fn unit_bid_price(&self) -> Option<Rational64>;
40 fn unit_bid_price_mut(&mut self) -> &mut Option<Rational64>;
41
42 fn unit_ask_price(&self) -> Option<Rational64>;
44 fn unit_ask_price_mut(&mut self) -> &mut Option<Rational64>;
45
46 fn unit_delta(&self) -> Option<NotNan<f64>>;
48
49 fn unit_vega(&self) -> Option<NotNan<f64>>;
51
52 fn unit_theta(&self) -> Option<NotNan<f64>>;
54
55 fn quantity(&self) -> Rational64;
57 fn quantity_mut(&mut self) -> &mut Rational64;
58
59 fn signed_quantity(&self) -> Rational64 {
61 if self.is_long() {
62 self.quantity()
63 } else {
64 -self.quantity()
65 }
66 }
67
68 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 fn bid_price(&self) -> Option<Rational64> {
79 self.unit_bid_price().map(|x| x * self.quantity())
80 }
81
82 fn ask_price(&self) -> Option<Rational64> {
84 self.unit_ask_price().map(|x| x * self.quantity())
85 }
86
87 fn mid_price(&self) -> Option<Rational64> {
89 self.unit_mid_price().map(|x| x * self.quantity())
90 }
91
92 fn unit_mid_price(&self) -> Option<Rational64> {
94 Some((self.unit_bid_price()? + self.unit_ask_price()?) / 2)
95 }
96
97 fn delta(&self) -> Option<NotNan<f64>> {
99 NotNan::new(self.unit_delta()?.into_inner() * self.quantity().to_f64()?).ok()
100 }
101
102 fn vega(&self) -> Option<NotNan<f64>> {
104 NotNan::new(self.unit_vega()?.into_inner() * self.quantity().to_f64()?).ok()
105 }
106
107 fn theta(&self) -> Option<NotNan<f64>> {
109 NotNan::new(self.unit_theta()?.into_inner() * self.quantity().to_f64()?).ok()
110 }
111
112 fn equivalent_strike_price(&self) -> Rational64;
115
116 fn equivalent_option_type(&self) -> OptionType;
118
119 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 pub symbol: String,
141
142 pub underlying_symbol: String,
144
145 pub is_long: bool,
147
148 pub unit_cost: Option<Rational64>,
150
151 pub unit_bid_price: Option<Rational64>,
153
154 pub unit_ask_price: Option<Rational64>,
156
157 pub unit_delta: Option<NotNan<f64>>,
159
160 pub unit_vega: Option<NotNan<f64>>,
162
163 pub unit_theta: Option<NotNan<f64>>,
165
166 pub quantity: Rational64,
168
169 pub strike_price: Rational64,
171
172 pub option_type: OptionType,
174
175 pub expiration_date: ExpirationDate,
177
178 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 pub symbol: String,
294
295 pub is_long: bool,
297
298 pub unit_cost: Option<Rational64>,
300
301 pub unit_bid_price: Option<Rational64>,
303
304 pub unit_ask_price: Option<Rational64>,
306
307 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 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 pub is_ascending: bool,
543}
544
545pub 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 const MARGIN_SCALE_FACTOR: i64 = 1000;
567 let price_range = (
568 Rational64::zero(),
569 (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 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 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 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()), SharesPosition::mock(19, 50.into()), ];
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 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}