/******************************************************************************
Author: Joaquín Béjar García
Email: jb@taunais.com
Date: 20/8/24
******************************************************************************/
use crate::model::option::ExoticParams;
use crate::model::{Options, Position};
use positive::Positive;
use rust_decimal::Decimal;
use std::fmt;
/// The label shown in place of an amount that stands for "unlimited".
pub const UNLIMITED: &str = "Unlimited";
/// The label shown in place of an amount that stands for "unlimited",
/// if `value` is one of the sentinels the library uses for it.
///
/// `Decimal::MAX` (which is also `Positive::MAX`, the value the strategies'
/// `get_max_profit` / `get_max_loss` return for an unbounded side) gives
/// `Unlimited`, and `Decimal::MIN` gives `-Unlimited`. Every other value,
/// however large, gives `None`.
#[inline]
#[must_use]
pub fn unlimited_label(value: Decimal) -> Option<&'static str> {
if value == Decimal::MAX {
Some(UNLIMITED)
} else if value == Decimal::MIN {
Some("-Unlimited")
} else {
None
}
}
/// A monetary amount as shown to a user: `$` followed by the amount, or the
/// [`unlimited_label`] when the amount is an "unlimited" sentinel.
///
/// Display only: the wrapped value is not changed. The formatter's precision
/// and width apply to the number only, so `format!("{:.2}", DisplayMoney(x))`
/// prints `$12.34` for a finite amount and `Unlimited` for `Decimal::MAX`.
///
/// ```
/// use optionstratlib_core::model::DisplayMoney;
/// use positive::Positive;
/// use rust_decimal_macros::dec;
///
/// assert_eq!(format!("{:.2}", DisplayMoney(dec!(12.34))), "$12.34");
/// assert_eq!(DisplayMoney::from(Positive::MAX).to_string(), "Unlimited");
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DisplayMoney(pub Decimal);
impl From<Decimal> for DisplayMoney {
#[inline]
fn from(value: Decimal) -> Self {
Self(value)
}
}
/// Normalised, so a `Positive` printed without a precision keeps the
/// trailing-zero-free rendering of `Positive`'s own `Display`.
impl From<Positive> for DisplayMoney {
#[inline]
fn from(value: Positive) -> Self {
Self(value.to_dec().normalize())
}
}
impl fmt::Display for DisplayMoney {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match unlimited_label(self.0) {
// Not `pad`: a precision would truncate the label.
Some(label) => f.write_str(label),
None => {
f.write_str("$")?;
fmt::Display::fmt(&self.0, f)
}
}
}
}
/// Writes `fraction` as a percentage with two decimals. A fraction whose
/// percentage leaves the `Decimal` range is written as the fraction itself,
/// marked as such; the multiplication aborted on it (#788).
fn write_percentage(f: &mut fmt::Formatter<'_>, fraction: Decimal) -> fmt::Result {
match fraction.checked_mul(Decimal::ONE_HUNDRED) {
Some(percent) => write!(f, "{percent:.2}%"),
None => write!(f, "{fraction} (fraction)"),
}
}
impl fmt::Display for Options {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"{} {} {}",
self.side, self.option_style, self.option_type
)?;
writeln!(
f,
"Underlying: {} @ ${:.2}",
self.underlying_symbol, self.underlying_price
)?;
writeln!(f, "Strike: ${:.2}", self.strike_price)?;
writeln!(f, "Expiration: {}", self.expiration_date)?;
write!(f, "Implied Volatility: ")?;
write_percentage(f, self.implied_volatility.to_dec())?;
writeln!(f)?;
writeln!(f, "Quantity: {}", self.quantity)?;
// Shown only when it departs from the default of one unit per
// contract, so the rendering of every pre-existing option is unchanged.
if self.contract_size != Positive::ONE {
writeln!(f, "Contract Size: {}", self.contract_size)?;
}
write!(f, "Risk-free Rate: ")?;
write_percentage(f, self.risk_free_rate)?;
writeln!(f)?;
write!(f, "Dividend Yield: ")?;
write_percentage(f, self.dividend_yield.to_dec())?;
if let Some(exotic) = &self.exotic_params {
write!(f, "\nExotic Parameters: {exotic:?}")?;
}
Ok(())
}
}
impl fmt::Debug for Options {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("Options");
debug
.field("option_type", &self.option_type)
.field("side", &self.side)
.field("underlying_symbol", &self.underlying_symbol)
.field("strike_price", &self.strike_price)
.field("expiration_date", &self.expiration_date)
.field("implied_volatility", &self.implied_volatility)
.field("quantity", &self.quantity);
// Shown only when it departs from the default of one unit per
// contract, so the rendering of every pre-existing option is unchanged.
if self.contract_size != Positive::ONE {
debug.field("contract_size", &self.contract_size);
}
debug
.field("underlying_price", &self.underlying_price)
.field("risk_free_rate", &self.risk_free_rate)
.field("option_style", &self.option_style)
.field("dividend_yield", &self.dividend_yield)
.field("exotic_params", &self.exotic_params)
.finish()
}
}
impl fmt::Display for ExoticParams {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut fields = vec![];
if let Some(ref prices) = self.spot_prices {
fields.push(format!("Spot Prices: {prices:?}"));
}
if let Some(min) = self.spot_min {
fields.push(format!("Spot Min: {min:.2}"));
}
if let Some(max) = self.spot_max {
fields.push(format!("Spot Max: {max:.2}"));
}
if let Some(cap) = self.cliquet_local_cap {
fields.push(format!("Cliquet Local Cap: {cap:.2}"));
}
if let Some(floor) = self.cliquet_local_floor {
fields.push(format!("Cliquet Local Floor: {floor:.2}"));
}
if let Some(cap) = self.cliquet_global_cap {
fields.push(format!("Cliquet Global Cap: {cap:.2}"));
}
if let Some(floor) = self.cliquet_global_floor {
fields.push(format!("Cliquet Global Floor: {floor:.2}"));
}
if let Some(ref price) = self.rainbow_second_asset_price {
fields.push(format!("Rainbow Second Asset Price: {price}"));
}
if let Some(ref vol) = self.rainbow_second_asset_volatility {
fields.push(format!("Rainbow Second Asset Volatility: {vol}"));
}
if let Some(ref div) = self.rainbow_second_asset_dividend {
fields.push(format!("Rainbow Second Asset Dividend: {div}"));
}
if let Some(corr) = self.rainbow_correlation {
fields.push(format!("Rainbow Correlation: {corr:.4}"));
}
if let Some(ref vol) = self.spread_second_asset_volatility {
fields.push(format!("Spread Second Asset Volatility: {vol}"));
}
if let Some(ref div) = self.spread_second_asset_dividend {
fields.push(format!("Spread Second Asset Dividend: {div}"));
}
if let Some(corr) = self.spread_correlation {
fields.push(format!("Spread Correlation: {corr:.4}"));
}
if let Some(ref vol) = self.quanto_fx_volatility {
fields.push(format!("Quanto FX Volatility: {vol}"));
}
if let Some(corr) = self.quanto_fx_correlation {
fields.push(format!("Quanto FX Correlation: {corr:.4}"));
}
if let Some(rate) = self.quanto_foreign_rate {
fields.push(format!("Quanto Foreign Rate: {rate:.4}"));
}
if let Some(rate) = self.foreign_rate {
fields.push(format!("Foreign Rate: {rate:.4}"));
}
if let Some(ref vol) = self.exchange_second_asset_volatility {
fields.push(format!("Exchange Second Asset Volatility: {vol}"));
}
if let Some(ref div) = self.exchange_second_asset_dividend {
fields.push(format!("Exchange Second Asset Dividend: {div}"));
}
if let Some(corr) = self.exchange_correlation {
fields.push(format!("Exchange Correlation: {corr:.4}"));
}
write!(f, "{}", fields.join(", "))
}
}
impl fmt::Display for Position {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Position Details:")?;
writeln!(f, "Option: {}", self.option)?;
writeln!(f, "Premium per contract: ${:.2}", self.premium)?;
writeln!(f, "Date: {}", self.date)?;
writeln!(f, "Open Fee per contract: ${:.2}", self.open_fee)?;
write!(f, "Close Fee per contract: ${:.2}", self.close_fee)
}
}
impl fmt::Debug for Position {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Position")
.field("option", &self.option)
.field("premium", &self.premium)
.field("date", &self.date)
.field("open_fee", &self.open_fee)
.field("close_fee", &self.close_fee)
.finish()
}
}
#[cfg(test)]
mod tests_options {
use super::*;
use crate::model::types::{BarrierType, OptionStyle, OptionType, Side};
use chrono::{NaiveDate, TimeZone, Utc};
use expiration_date::ExpirationDate;
use positive::{Positive, pos_or_panic};
use rust_decimal_macros::dec;
// `Positive * 100.0` aborted with `Positive arithmetic overflow in
// mul_f64` on a volatility above `Decimal::MAX / 100` (#788).
#[test]
fn test_display_options_out_of_range_percentages() {
let mut options =
crate::model::utils::create_sample_option_simplest(OptionStyle::Call, Side::Long);
options.implied_volatility = Positive::MAX;
options.dividend_yield = Positive::MAX;
options.risk_free_rate = Decimal::MIN;
let shown = options.to_string();
assert!(shown.contains(&format!("Implied Volatility: {} (fraction)", Positive::MAX)));
assert!(shown.contains(&format!("Risk-free Rate: {} (fraction)", Decimal::MIN)));
assert!(shown.contains(&format!("Dividend Yield: {} (fraction)", Positive::MAX)));
}
#[test]
fn test_debug_options() {
let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
.expect("Invalid date")
.and_hms_opt(0, 0, 0)
.expect("Invalid time");
let options = Options {
option_type: OptionType::European,
side: Side::Long,
underlying_symbol: "AAPL".to_string(),
strike_price: pos_or_panic!(150.0),
expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
implied_volatility: pos_or_panic!(0.25),
quantity: pos_or_panic!(10.0),
contract_size: Positive::ONE,
underlying_price: pos_or_panic!(155.0),
risk_free_rate: dec!(0.01),
option_style: OptionStyle::Call,
dividend_yield: pos_or_panic!(0.02),
exotic_params: None,
};
let debug_output = format!("{options:?}");
let expected_output = "Options { \
option_type: European, \
side: Side::Long, \
underlying_symbol: \"AAPL\", \
strike_price: 150, \
expiration_date: DateTime(2024-08-08T00:00:00Z), \
implied_volatility: 0.25, \
quantity: 10, \
underlying_price: 155, \
risk_free_rate: 0.01, \
option_style: OptionStyle::Call, \
dividend_yield: 0.02, \
exotic_params: None \
}";
assert_eq!(debug_output, expected_output);
}
#[test]
fn test_display_options() {
let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
.expect("Invalid date")
.and_hms_opt(0, 0, 0)
.expect("Invalid time");
let options = Options {
option_type: OptionType::European,
side: Side::Long,
underlying_symbol: "AAPL".to_string(),
strike_price: pos_or_panic!(150.0),
expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
implied_volatility: pos_or_panic!(0.25),
quantity: pos_or_panic!(10.0),
contract_size: Positive::ONE,
underlying_price: pos_or_panic!(155.0),
risk_free_rate: dec!(0.01),
option_style: OptionStyle::Call,
dividend_yield: pos_or_panic!(0.02),
exotic_params: None,
};
let display_output = format!("{options}");
let expected_output = "\
Long Call European Option\n\
Underlying: AAPL @ $155.00\n\
Strike: $150.00\n\
Expiration: 2024-08-08 00:00:00 UTC\n\
Implied Volatility: 25.00%\n\
Quantity: 10\n\
Risk-free Rate: 1.00%\n\
Dividend Yield: 2.00%";
assert_eq!(display_output, expected_output);
}
#[test]
fn test_display_exotic_params_shows_signed_foreign_rate() {
let params = ExoticParams {
foreign_rate: Some(rust_decimal::Decimal::new(-75, 4)),
..ExoticParams::default()
};
assert_eq!(params.to_string(), "Foreign Rate: -0.0075");
}
#[test]
fn test_display_options_with_exotic_params() {
let exotic_params = ExoticParams {
spot_prices: None,
spot_min: None,
spot_max: None,
cliquet_local_cap: None,
cliquet_local_floor: None,
cliquet_global_cap: None,
cliquet_global_floor: None,
rainbow_second_asset_price: None,
rainbow_second_asset_volatility: None,
rainbow_second_asset_dividend: None,
rainbow_correlation: None,
spread_second_asset_volatility: None,
spread_second_asset_dividend: None,
spread_correlation: None,
quanto_fx_volatility: None,
quanto_fx_correlation: None,
quanto_foreign_rate: None,
foreign_rate: None,
exchange_second_asset_volatility: None,
exchange_second_asset_dividend: None,
exchange_correlation: None,
};
let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
.expect("Invalid date")
.and_hms_opt(0, 0, 0)
.expect("Invalid time");
let options = Options {
option_type: OptionType::Barrier {
barrier_type: BarrierType::UpAndIn,
barrier_level: pos_or_panic!(100.0),
rebate: None,
},
side: Side::Short,
underlying_symbol: "GOOGL".to_string(),
strike_price: pos_or_panic!(2000.0),
expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
implied_volatility: pos_or_panic!(0.30),
quantity: pos_or_panic!(5.0),
contract_size: Positive::ONE,
underlying_price: pos_or_panic!(1900.0),
risk_free_rate: dec!(0.015),
option_style: OptionStyle::Call,
dividend_yield: pos_or_panic!(0.01),
exotic_params: Some(exotic_params),
};
let display_output = format!("{options}");
let expected_output = "\
Short Call Barrier Option (Type: Up-And-In Barrier, Level: 100, Rebate: None)\n\
Underlying: GOOGL @ $1900.00\n\
Strike: $2000.00\n\
Expiration: 2024-08-08 00:00:00 UTC\n\
Implied Volatility: 30.00%\n\
Quantity: 5\n\
Risk-free Rate: 1.50%\n\
Dividend Yield: 1.00%\n\
Exotic Parameters: ExoticParams { spot_prices: None, spot_min: None, spot_max: None, cliquet_local_cap: None, cliquet_local_floor: None, cliquet_global_cap: None, cliquet_global_floor: None, rainbow_second_asset_price: None, rainbow_second_asset_volatility: None, rainbow_second_asset_dividend: None, rainbow_correlation: None, spread_second_asset_volatility: None, spread_second_asset_dividend: None, spread_correlation: None, quanto_fx_volatility: None, quanto_fx_correlation: None, quanto_foreign_rate: None, foreign_rate: None, exchange_second_asset_volatility: None, exchange_second_asset_dividend: None, exchange_correlation: None }";
assert_eq!(display_output, expected_output);
}
}
#[cfg(test)]
mod tests_expiration_date {
use chrono::{Duration, NaiveDate, TimeZone, Utc};
use expiration_date::ExpirationDate;
use positive::pos_or_panic;
use tracing::info;
#[test]
fn test_display_days() {
let expiration = ExpirationDate::Days(pos_or_panic!(30.5));
let display_string = format!("{expiration}");
assert!(display_string.contains("UTC"));
}
#[test]
fn test_display_datetime() {
let future_date = Utc::now() + Duration::days(15) + Duration::minutes(1);
let expiration = ExpirationDate::DateTime(future_date);
let display_string = format!("{expiration}");
assert!(display_string.contains("UTC"));
assert!(display_string.contains(&future_date.format("%Y-%m-%d %H:%M:%S").to_string()));
}
#[test]
fn test_debug_days() {
let expiration = ExpirationDate::Days(pos_or_panic!(45.75));
let debug_string = format!("{expiration:?}");
assert_eq!(debug_string, "Days(45.75)");
}
#[test]
fn test_debug_datetime() {
let date = Utc.from_utc_datetime(
&NaiveDate::from_ymd_opt(2023, 12, 31)
.expect("Invalid date")
.and_hms_opt(23, 59, 59)
.expect("Invalid Time"),
);
let expiration = ExpirationDate::DateTime(date);
let debug_string = format!("{expiration:?}");
assert_eq!(debug_string, "DateTime(2023-12-31T23:59:59Z)");
}
#[test]
fn test_display_past_date() {
let past_date = Utc::now() - Duration::days(5);
let expiration = ExpirationDate::DateTime(past_date);
let display_string = format!("{expiration}");
assert!(display_string.contains("UTC"));
assert!(display_string.contains(&past_date.format("%Y-%m-%d %H:%M:%S").to_string()));
}
#[test]
fn test_display_today() {
let today = Utc::now();
let expiration = ExpirationDate::DateTime(today);
let display_string = format!("{expiration}");
info!("{}", display_string);
assert!(display_string.contains(&today.format("%Y-%m-%d %H:%M:%S").to_string()));
}
}
#[cfg(test)]
mod tests_position_type_display_debug {
use super::*;
use crate::model::types::{OptionStyle, OptionType, Side};
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
use expiration_date::ExpirationDate;
use positive::{Positive, pos_or_panic};
use rust_decimal_macros::dec;
fn get_option() -> (Options, DateTime<Utc>) {
let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
.expect("Invalid date")
.and_hms_opt(0, 0, 0)
.expect("Invalid time");
(
Options {
option_type: OptionType::European,
side: Side::Long,
underlying_symbol: "AAPL".to_string(),
strike_price: pos_or_panic!(150.0),
expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
implied_volatility: pos_or_panic!(0.25),
quantity: pos_or_panic!(10.0),
contract_size: Positive::ONE,
underlying_price: pos_or_panic!(155.0),
risk_free_rate: dec!(0.01),
option_style: OptionStyle::Call,
dividend_yield: pos_or_panic!(0.02),
exotic_params: None,
},
Utc.from_utc_datetime(&naive_date),
)
}
#[test]
fn test_position_display() {
let (option, naive_date) = get_option();
let position = Position {
option,
premium: pos_or_panic!(5.75),
date: naive_date,
open_fee: pos_or_panic!(0.50),
close_fee: pos_or_panic!(0.45),
epic: Some("Epic123".to_string()),
extra_fields: None,
};
let expected_display = "Position Details:\n\
Option: Long Call European Option\n\
Underlying: AAPL @ $155.00\n\
Strike: $150.00\n\
Expiration: 2024-08-08 00:00:00 UTC\n\
Implied Volatility: 25.00%\n\
Quantity: 10\n\
Risk-free Rate: 1.00%\n\
Dividend Yield: 2.00%\n\
Premium per contract: $5.75\n\
Date: 2024-08-08 00:00:00 UTC\n\
Open Fee per contract: $0.50\n\
Close Fee per contract: $0.45";
assert_eq!(format!("{position}"), expected_display);
}
#[test]
fn test_position_debug() {
let (option, naive_date) = get_option();
let position = Position {
option,
premium: pos_or_panic!(5.75),
date: naive_date,
open_fee: pos_or_panic!(0.50),
close_fee: pos_or_panic!(0.45),
epic: Some("Epic123".to_string()),
extra_fields: None,
};
let expected_debug = "Position { \
option: Options { \
option_type: European, \
side: Side::Long, \
underlying_symbol: \"AAPL\", \
strike_price: 150, \
expiration_date: DateTime(2024-08-08T00:00:00Z), \
implied_volatility: 0.25, \
quantity: 10, \
underlying_price: 155, \
risk_free_rate: 0.01, \
option_style: OptionStyle::Call, \
dividend_yield: 0.02, \
exotic_params: None \
}, \
premium: 5.75, \
date: 2024-08-08T00:00:00Z, \
open_fee: 0.5, \
close_fee: 0.45 \
}";
assert_eq!(format!("{position:?}"), expected_debug);
}
}
#[cfg(test)]
mod tests_display_money {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn test_display_money_finite_amount_keeps_precision() {
assert_eq!(format!("{:.2}", DisplayMoney(dec!(12.5))), "$12.50");
assert_eq!(format!("{:.2}", DisplayMoney(dec!(-3.25))), "$-3.25");
assert_eq!(DisplayMoney(dec!(7)).to_string(), "$7");
assert_eq!(
DisplayMoney::from(positive::pos_or_panic!(5.00)).to_string(),
"$5"
);
}
#[test]
fn test_display_money_decimal_max_renders_unlimited() {
assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MAX)), "Unlimited");
assert_eq!(DisplayMoney::from(Positive::MAX).to_string(), "Unlimited");
}
#[test]
fn test_display_money_decimal_min_renders_negative_unlimited() {
assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MIN)), "-Unlimited");
}
#[test]
fn test_unlimited_label_only_for_sentinels() {
assert_eq!(unlimited_label(Decimal::MAX), Some(UNLIMITED));
assert_eq!(unlimited_label(Decimal::MIN), Some("-Unlimited"));
assert_eq!(unlimited_label(Decimal::MAX - Decimal::ONE), None);
assert_eq!(unlimited_label(Decimal::ZERO), None);
}
}