1use crate::model::option::ExoticParams;
7use crate::model::{Options, Position};
8use positive::Positive;
9use rust_decimal::Decimal;
10use std::fmt;
11
12pub const UNLIMITED: &str = "Unlimited";
14
15#[inline]
23#[must_use]
24pub fn unlimited_label(value: Decimal) -> Option<&'static str> {
25 if value == Decimal::MAX {
26 Some(UNLIMITED)
27 } else if value == Decimal::MIN {
28 Some("-Unlimited")
29 } else {
30 None
31 }
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct DisplayMoney(pub Decimal);
51
52impl From<Decimal> for DisplayMoney {
53 #[inline]
54 fn from(value: Decimal) -> Self {
55 Self(value)
56 }
57}
58
59impl From<Positive> for DisplayMoney {
62 #[inline]
63 fn from(value: Positive) -> Self {
64 Self(value.to_dec().normalize())
65 }
66}
67
68impl fmt::Display for DisplayMoney {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match unlimited_label(self.0) {
71 Some(label) => f.write_str(label),
73 None => {
74 f.write_str("$")?;
75 fmt::Display::fmt(&self.0, f)
76 }
77 }
78 }
79}
80
81fn write_percentage(f: &mut fmt::Formatter<'_>, fraction: Decimal) -> fmt::Result {
85 match fraction.checked_mul(Decimal::ONE_HUNDRED) {
86 Some(percent) => write!(f, "{percent:.2}%"),
87 None => write!(f, "{fraction} (fraction)"),
88 }
89}
90
91impl fmt::Display for Options {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 writeln!(
94 f,
95 "{} {} {}",
96 self.side, self.option_style, self.option_type
97 )?;
98 writeln!(
99 f,
100 "Underlying: {} @ ${:.2}",
101 self.underlying_symbol, self.underlying_price
102 )?;
103 writeln!(f, "Strike: ${:.2}", self.strike_price)?;
104 writeln!(f, "Expiration: {}", self.expiration_date)?;
105 write!(f, "Implied Volatility: ")?;
106 write_percentage(f, self.implied_volatility.to_dec())?;
107 writeln!(f)?;
108 writeln!(f, "Quantity: {}", self.quantity)?;
109 if self.contract_size != Positive::ONE {
112 writeln!(f, "Contract Size: {}", self.contract_size)?;
113 }
114 write!(f, "Risk-free Rate: ")?;
115 write_percentage(f, self.risk_free_rate)?;
116 writeln!(f)?;
117 write!(f, "Dividend Yield: ")?;
118 write_percentage(f, self.dividend_yield.to_dec())?;
119 if let Some(exotic) = &self.exotic_params {
120 write!(f, "\nExotic Parameters: {exotic:?}")?;
121 }
122 Ok(())
123 }
124}
125
126impl fmt::Debug for Options {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 let mut debug = f.debug_struct("Options");
129 debug
130 .field("option_type", &self.option_type)
131 .field("side", &self.side)
132 .field("underlying_symbol", &self.underlying_symbol)
133 .field("strike_price", &self.strike_price)
134 .field("expiration_date", &self.expiration_date)
135 .field("implied_volatility", &self.implied_volatility)
136 .field("quantity", &self.quantity);
137 if self.contract_size != Positive::ONE {
140 debug.field("contract_size", &self.contract_size);
141 }
142 debug
143 .field("underlying_price", &self.underlying_price)
144 .field("risk_free_rate", &self.risk_free_rate)
145 .field("option_style", &self.option_style)
146 .field("dividend_yield", &self.dividend_yield)
147 .field("exotic_params", &self.exotic_params)
148 .finish()
149 }
150}
151
152impl fmt::Display for ExoticParams {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 let mut fields = vec![];
155
156 if let Some(ref prices) = self.spot_prices {
157 fields.push(format!("Spot Prices: {prices:?}"));
158 }
159
160 if let Some(min) = self.spot_min {
161 fields.push(format!("Spot Min: {min:.2}"));
162 }
163
164 if let Some(max) = self.spot_max {
165 fields.push(format!("Spot Max: {max:.2}"));
166 }
167
168 if let Some(cap) = self.cliquet_local_cap {
169 fields.push(format!("Cliquet Local Cap: {cap:.2}"));
170 }
171
172 if let Some(floor) = self.cliquet_local_floor {
173 fields.push(format!("Cliquet Local Floor: {floor:.2}"));
174 }
175
176 if let Some(cap) = self.cliquet_global_cap {
177 fields.push(format!("Cliquet Global Cap: {cap:.2}"));
178 }
179
180 if let Some(floor) = self.cliquet_global_floor {
181 fields.push(format!("Cliquet Global Floor: {floor:.2}"));
182 }
183
184 if let Some(ref price) = self.rainbow_second_asset_price {
185 fields.push(format!("Rainbow Second Asset Price: {price}"));
186 }
187
188 if let Some(ref vol) = self.rainbow_second_asset_volatility {
189 fields.push(format!("Rainbow Second Asset Volatility: {vol}"));
190 }
191
192 if let Some(ref div) = self.rainbow_second_asset_dividend {
193 fields.push(format!("Rainbow Second Asset Dividend: {div}"));
194 }
195
196 if let Some(corr) = self.rainbow_correlation {
197 fields.push(format!("Rainbow Correlation: {corr:.4}"));
198 }
199
200 if let Some(ref vol) = self.spread_second_asset_volatility {
201 fields.push(format!("Spread Second Asset Volatility: {vol}"));
202 }
203
204 if let Some(ref div) = self.spread_second_asset_dividend {
205 fields.push(format!("Spread Second Asset Dividend: {div}"));
206 }
207
208 if let Some(corr) = self.spread_correlation {
209 fields.push(format!("Spread Correlation: {corr:.4}"));
210 }
211
212 if let Some(ref vol) = self.quanto_fx_volatility {
213 fields.push(format!("Quanto FX Volatility: {vol}"));
214 }
215
216 if let Some(corr) = self.quanto_fx_correlation {
217 fields.push(format!("Quanto FX Correlation: {corr:.4}"));
218 }
219
220 if let Some(rate) = self.quanto_foreign_rate {
221 fields.push(format!("Quanto Foreign Rate: {rate:.4}"));
222 }
223
224 if let Some(rate) = self.foreign_rate {
225 fields.push(format!("Foreign Rate: {rate:.4}"));
226 }
227
228 if let Some(ref vol) = self.exchange_second_asset_volatility {
229 fields.push(format!("Exchange Second Asset Volatility: {vol}"));
230 }
231
232 if let Some(ref div) = self.exchange_second_asset_dividend {
233 fields.push(format!("Exchange Second Asset Dividend: {div}"));
234 }
235
236 if let Some(corr) = self.exchange_correlation {
237 fields.push(format!("Exchange Correlation: {corr:.4}"));
238 }
239
240 write!(f, "{}", fields.join(", "))
241 }
242}
243
244impl fmt::Display for Position {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 writeln!(f, "Position Details:")?;
247 writeln!(f, "Option: {}", self.option)?;
248 writeln!(f, "Premium per contract: ${:.2}", self.premium)?;
249 writeln!(f, "Date: {}", self.date)?;
250 writeln!(f, "Open Fee per contract: ${:.2}", self.open_fee)?;
251 write!(f, "Close Fee per contract: ${:.2}", self.close_fee)
252 }
253}
254
255impl fmt::Debug for Position {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 f.debug_struct("Position")
258 .field("option", &self.option)
259 .field("premium", &self.premium)
260 .field("date", &self.date)
261 .field("open_fee", &self.open_fee)
262 .field("close_fee", &self.close_fee)
263 .finish()
264 }
265}
266
267#[cfg(test)]
268mod tests_options {
269 use super::*;
270 use crate::model::types::{BarrierType, OptionStyle, OptionType, Side};
271
272 use chrono::{NaiveDate, TimeZone, Utc};
273 use expiration_date::ExpirationDate;
274 use positive::{Positive, pos_or_panic};
275 use rust_decimal_macros::dec;
276
277 #[test]
280 fn test_display_options_out_of_range_percentages() {
281 let mut options =
282 crate::model::utils::create_sample_option_simplest(OptionStyle::Call, Side::Long);
283 options.implied_volatility = Positive::MAX;
284 options.dividend_yield = Positive::MAX;
285 options.risk_free_rate = Decimal::MIN;
286 let shown = options.to_string();
287 assert!(shown.contains(&format!("Implied Volatility: {} (fraction)", Positive::MAX)));
288 assert!(shown.contains(&format!("Risk-free Rate: {} (fraction)", Decimal::MIN)));
289 assert!(shown.contains(&format!("Dividend Yield: {} (fraction)", Positive::MAX)));
290 }
291
292 #[test]
293 fn test_debug_options() {
294 let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
295 .expect("Invalid date")
296 .and_hms_opt(0, 0, 0)
297 .expect("Invalid time");
298
299 let options = Options {
300 option_type: OptionType::European,
301 side: Side::Long,
302 underlying_symbol: "AAPL".to_string(),
303 strike_price: pos_or_panic!(150.0),
304 expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
305 implied_volatility: pos_or_panic!(0.25),
306 quantity: pos_or_panic!(10.0),
307 contract_size: Positive::ONE,
308 underlying_price: pos_or_panic!(155.0),
309 risk_free_rate: dec!(0.01),
310 option_style: OptionStyle::Call,
311 dividend_yield: pos_or_panic!(0.02),
312 exotic_params: None,
313 };
314
315 let debug_output = format!("{options:?}");
316 let expected_output = "Options { \
317 option_type: European, \
318 side: Side::Long, \
319 underlying_symbol: \"AAPL\", \
320 strike_price: 150, \
321 expiration_date: DateTime(2024-08-08T00:00:00Z), \
322 implied_volatility: 0.25, \
323 quantity: 10, \
324 underlying_price: 155, \
325 risk_free_rate: 0.01, \
326 option_style: OptionStyle::Call, \
327 dividend_yield: 0.02, \
328 exotic_params: None \
329 }";
330
331 assert_eq!(debug_output, expected_output);
332 }
333
334 #[test]
335 fn test_display_options() {
336 let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
337 .expect("Invalid date")
338 .and_hms_opt(0, 0, 0)
339 .expect("Invalid time");
340
341 let options = Options {
342 option_type: OptionType::European,
343 side: Side::Long,
344 underlying_symbol: "AAPL".to_string(),
345 strike_price: pos_or_panic!(150.0),
346 expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
347 implied_volatility: pos_or_panic!(0.25),
348 quantity: pos_or_panic!(10.0),
349 contract_size: Positive::ONE,
350 underlying_price: pos_or_panic!(155.0),
351 risk_free_rate: dec!(0.01),
352 option_style: OptionStyle::Call,
353 dividend_yield: pos_or_panic!(0.02),
354 exotic_params: None,
355 };
356
357 let display_output = format!("{options}");
358 let expected_output = "\
359 Long Call European Option\n\
360 Underlying: AAPL @ $155.00\n\
361 Strike: $150.00\n\
362 Expiration: 2024-08-08 00:00:00 UTC\n\
363 Implied Volatility: 25.00%\n\
364 Quantity: 10\n\
365 Risk-free Rate: 1.00%\n\
366 Dividend Yield: 2.00%";
367
368 assert_eq!(display_output, expected_output);
369 }
370
371 #[test]
372 fn test_display_exotic_params_shows_signed_foreign_rate() {
373 let params = ExoticParams {
374 foreign_rate: Some(rust_decimal::Decimal::new(-75, 4)),
375 ..ExoticParams::default()
376 };
377 assert_eq!(params.to_string(), "Foreign Rate: -0.0075");
378 }
379
380 #[test]
381 fn test_display_options_with_exotic_params() {
382 let exotic_params = ExoticParams {
383 spot_prices: None,
384 spot_min: None,
385 spot_max: None,
386 cliquet_local_cap: None,
387 cliquet_local_floor: None,
388 cliquet_global_cap: None,
389 cliquet_global_floor: None,
390 rainbow_second_asset_price: None,
391 rainbow_second_asset_volatility: None,
392 rainbow_second_asset_dividend: None,
393 rainbow_correlation: None,
394 spread_second_asset_volatility: None,
395 spread_second_asset_dividend: None,
396 spread_correlation: None,
397 quanto_fx_volatility: None,
398 quanto_fx_correlation: None,
399 quanto_foreign_rate: None,
400 foreign_rate: None,
401 exchange_second_asset_volatility: None,
402 exchange_second_asset_dividend: None,
403 exchange_correlation: None,
404 };
405 let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
406 .expect("Invalid date")
407 .and_hms_opt(0, 0, 0)
408 .expect("Invalid time");
409
410 let options = Options {
411 option_type: OptionType::Barrier {
412 barrier_type: BarrierType::UpAndIn,
413 barrier_level: pos_or_panic!(100.0),
414 rebate: None,
415 },
416 side: Side::Short,
417 underlying_symbol: "GOOGL".to_string(),
418 strike_price: pos_or_panic!(2000.0),
419 expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
420 implied_volatility: pos_or_panic!(0.30),
421 quantity: pos_or_panic!(5.0),
422 contract_size: Positive::ONE,
423 underlying_price: pos_or_panic!(1900.0),
424 risk_free_rate: dec!(0.015),
425 option_style: OptionStyle::Call,
426 dividend_yield: pos_or_panic!(0.01),
427 exotic_params: Some(exotic_params),
428 };
429
430 let display_output = format!("{options}");
431 let expected_output = "\
432 Short Call Barrier Option (Type: Up-And-In Barrier, Level: 100, Rebate: None)\n\
433 Underlying: GOOGL @ $1900.00\n\
434 Strike: $2000.00\n\
435 Expiration: 2024-08-08 00:00:00 UTC\n\
436 Implied Volatility: 30.00%\n\
437 Quantity: 5\n\
438 Risk-free Rate: 1.50%\n\
439 Dividend Yield: 1.00%\n\
440 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 }";
441
442 assert_eq!(display_output, expected_output);
443 }
444}
445
446#[cfg(test)]
447mod tests_expiration_date {
448
449 use chrono::{Duration, NaiveDate, TimeZone, Utc};
450 use expiration_date::ExpirationDate;
451 use positive::pos_or_panic;
452 use tracing::info;
453
454 #[test]
455 fn test_display_days() {
456 let expiration = ExpirationDate::Days(pos_or_panic!(30.5));
457 let display_string = format!("{expiration}");
458 assert!(display_string.contains("UTC"));
459 }
460
461 #[test]
462 fn test_display_datetime() {
463 let future_date = Utc::now() + Duration::days(15) + Duration::minutes(1);
464 let expiration = ExpirationDate::DateTime(future_date);
465 let display_string = format!("{expiration}");
466 assert!(display_string.contains("UTC"));
467 assert!(display_string.contains(&future_date.format("%Y-%m-%d %H:%M:%S").to_string()));
468 }
469
470 #[test]
471 fn test_debug_days() {
472 let expiration = ExpirationDate::Days(pos_or_panic!(45.75));
473 let debug_string = format!("{expiration:?}");
474 assert_eq!(debug_string, "Days(45.75)");
475 }
476
477 #[test]
478 fn test_debug_datetime() {
479 let date = Utc.from_utc_datetime(
480 &NaiveDate::from_ymd_opt(2023, 12, 31)
481 .expect("Invalid date")
482 .and_hms_opt(23, 59, 59)
483 .expect("Invalid Time"),
484 );
485
486 let expiration = ExpirationDate::DateTime(date);
487 let debug_string = format!("{expiration:?}");
488 assert_eq!(debug_string, "DateTime(2023-12-31T23:59:59Z)");
489 }
490
491 #[test]
492 fn test_display_past_date() {
493 let past_date = Utc::now() - Duration::days(5);
494 let expiration = ExpirationDate::DateTime(past_date);
495 let display_string = format!("{expiration}");
496 assert!(display_string.contains("UTC"));
497 assert!(display_string.contains(&past_date.format("%Y-%m-%d %H:%M:%S").to_string()));
498 }
499
500 #[test]
501 fn test_display_today() {
502 let today = Utc::now();
503 let expiration = ExpirationDate::DateTime(today);
504 let display_string = format!("{expiration}");
505 info!("{}", display_string);
506 assert!(display_string.contains(&today.format("%Y-%m-%d %H:%M:%S").to_string()));
507 }
508}
509
510#[cfg(test)]
511mod tests_position_type_display_debug {
512 use super::*;
513 use crate::model::types::{OptionStyle, OptionType, Side};
514
515 use chrono::{DateTime, NaiveDate, TimeZone, Utc};
516 use expiration_date::ExpirationDate;
517 use positive::{Positive, pos_or_panic};
518 use rust_decimal_macros::dec;
519
520 fn get_option() -> (Options, DateTime<Utc>) {
521 let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
522 .expect("Invalid date")
523 .and_hms_opt(0, 0, 0)
524 .expect("Invalid time");
525
526 (
527 Options {
528 option_type: OptionType::European,
529 side: Side::Long,
530 underlying_symbol: "AAPL".to_string(),
531 strike_price: pos_or_panic!(150.0),
532 expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
533 implied_volatility: pos_or_panic!(0.25),
534 quantity: pos_or_panic!(10.0),
535 contract_size: Positive::ONE,
536 underlying_price: pos_or_panic!(155.0),
537 risk_free_rate: dec!(0.01),
538 option_style: OptionStyle::Call,
539 dividend_yield: pos_or_panic!(0.02),
540 exotic_params: None,
541 },
542 Utc.from_utc_datetime(&naive_date),
543 )
544 }
545
546 #[test]
547 fn test_position_display() {
548 let (option, naive_date) = get_option();
549 let position = Position {
550 option,
551 premium: pos_or_panic!(5.75),
552 date: naive_date,
553 open_fee: pos_or_panic!(0.50),
554 close_fee: pos_or_panic!(0.45),
555 epic: Some("Epic123".to_string()),
556 extra_fields: None,
557 };
558
559 let expected_display = "Position Details:\n\
560 Option: Long Call European Option\n\
561 Underlying: AAPL @ $155.00\n\
562 Strike: $150.00\n\
563 Expiration: 2024-08-08 00:00:00 UTC\n\
564 Implied Volatility: 25.00%\n\
565 Quantity: 10\n\
566 Risk-free Rate: 1.00%\n\
567 Dividend Yield: 2.00%\n\
568 Premium per contract: $5.75\n\
569 Date: 2024-08-08 00:00:00 UTC\n\
570 Open Fee per contract: $0.50\n\
571 Close Fee per contract: $0.45";
572
573 assert_eq!(format!("{position}"), expected_display);
574 }
575
576 #[test]
577 fn test_position_debug() {
578 let (option, naive_date) = get_option();
579
580 let position = Position {
581 option,
582 premium: pos_or_panic!(5.75),
583 date: naive_date,
584 open_fee: pos_or_panic!(0.50),
585 close_fee: pos_or_panic!(0.45),
586 epic: Some("Epic123".to_string()),
587 extra_fields: None,
588 };
589
590 let expected_debug = "Position { \
591 option: Options { \
592 option_type: European, \
593 side: Side::Long, \
594 underlying_symbol: \"AAPL\", \
595 strike_price: 150, \
596 expiration_date: DateTime(2024-08-08T00:00:00Z), \
597 implied_volatility: 0.25, \
598 quantity: 10, \
599 underlying_price: 155, \
600 risk_free_rate: 0.01, \
601 option_style: OptionStyle::Call, \
602 dividend_yield: 0.02, \
603 exotic_params: None \
604 }, \
605 premium: 5.75, \
606 date: 2024-08-08T00:00:00Z, \
607 open_fee: 0.5, \
608 close_fee: 0.45 \
609 }";
610
611 assert_eq!(format!("{position:?}"), expected_debug);
612 }
613}
614
615#[cfg(test)]
616mod tests_display_money {
617 use super::*;
618 use rust_decimal_macros::dec;
619
620 #[test]
621 fn test_display_money_finite_amount_keeps_precision() {
622 assert_eq!(format!("{:.2}", DisplayMoney(dec!(12.5))), "$12.50");
623 assert_eq!(format!("{:.2}", DisplayMoney(dec!(-3.25))), "$-3.25");
624 assert_eq!(DisplayMoney(dec!(7)).to_string(), "$7");
625 assert_eq!(
626 DisplayMoney::from(positive::pos_or_panic!(5.00)).to_string(),
627 "$5"
628 );
629 }
630
631 #[test]
632 fn test_display_money_decimal_max_renders_unlimited() {
633 assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MAX)), "Unlimited");
634 assert_eq!(DisplayMoney::from(Positive::MAX).to_string(), "Unlimited");
635 }
636
637 #[test]
638 fn test_display_money_decimal_min_renders_negative_unlimited() {
639 assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MIN)), "-Unlimited");
640 }
641
642 #[test]
643 fn test_unlimited_label_only_for_sentinels() {
644 assert_eq!(unlimited_label(Decimal::MAX), Some(UNLIMITED));
645 assert_eq!(unlimited_label(Decimal::MIN), Some("-Unlimited"));
646 assert_eq!(unlimited_label(Decimal::MAX - Decimal::ONE), None);
647 assert_eq!(unlimited_label(Decimal::ZERO), None);
648 }
649}