Skip to main content

optionstratlib_core/model/
format.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 20/8/24
5******************************************************************************/
6use crate::model::option::ExoticParams;
7use crate::model::{Options, Position};
8use positive::Positive;
9use rust_decimal::Decimal;
10use std::fmt;
11
12/// The label shown in place of an amount that stands for "unlimited".
13pub const UNLIMITED: &str = "Unlimited";
14
15/// The label shown in place of an amount that stands for "unlimited",
16/// if `value` is one of the sentinels the library uses for it.
17///
18/// `Decimal::MAX` (which is also `Positive::MAX`, the value the strategies'
19/// `get_max_profit` / `get_max_loss` return for an unbounded side) gives
20/// `Unlimited`, and `Decimal::MIN` gives `-Unlimited`. Every other value,
21/// however large, gives `None`.
22#[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/// A monetary amount as shown to a user: `$` followed by the amount, or the
35/// [`unlimited_label`] when the amount is an "unlimited" sentinel.
36///
37/// Display only: the wrapped value is not changed. The formatter's precision
38/// and width apply to the number only, so `format!("{:.2}", DisplayMoney(x))`
39/// prints `$12.34` for a finite amount and `Unlimited` for `Decimal::MAX`.
40///
41/// ```
42/// use optionstratlib_core::model::DisplayMoney;
43/// use positive::Positive;
44/// use rust_decimal_macros::dec;
45///
46/// assert_eq!(format!("{:.2}", DisplayMoney(dec!(12.34))), "$12.34");
47/// assert_eq!(DisplayMoney::from(Positive::MAX).to_string(), "Unlimited");
48/// ```
49#[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
59/// Normalised, so a `Positive` printed without a precision keeps the
60/// trailing-zero-free rendering of `Positive`'s own `Display`.
61impl 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            // Not `pad`: a precision would truncate the label.
72            Some(label) => f.write_str(label),
73            None => {
74                f.write_str("$")?;
75                fmt::Display::fmt(&self.0, f)
76            }
77        }
78    }
79}
80
81/// Writes `fraction` as a percentage with two decimals. A fraction whose
82/// percentage leaves the `Decimal` range is written as the fraction itself,
83/// marked as such; the multiplication aborted on it (#788).
84fn 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        // Shown only when it departs from the default of one unit per
110        // contract, so the rendering of every pre-existing option is unchanged.
111        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        // Shown only when it departs from the default of one unit per
138        // contract, so the rendering of every pre-existing option is unchanged.
139        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    // `Positive * 100.0` aborted with `Positive arithmetic overflow in
278    // mul_f64` on a volatility above `Decimal::MAX / 100` (#788).
279    #[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    #[cfg(feature = "clock")]
450    use chrono::Duration;
451    use chrono::{NaiveDate, TimeZone, Utc};
452    use expiration_date::ExpirationDate;
453    use positive::pos_or_panic;
454    #[cfg(feature = "clock")]
455    use tracing::info;
456
457    #[cfg(feature = "clock")]
458    #[test]
459    fn test_display_days() {
460        let expiration = ExpirationDate::Days(pos_or_panic!(30.5));
461        let display_string = format!("{expiration}");
462        assert!(display_string.contains("UTC"));
463    }
464
465    #[cfg(feature = "clock")]
466    #[test]
467    fn test_display_datetime() {
468        let future_date = Utc::now() + Duration::days(15) + Duration::minutes(1);
469        let expiration = ExpirationDate::DateTime(future_date);
470        let display_string = format!("{expiration}");
471        assert!(display_string.contains("UTC"));
472        assert!(display_string.contains(&future_date.format("%Y-%m-%d %H:%M:%S").to_string()));
473    }
474
475    #[test]
476    fn test_debug_days() {
477        let expiration = ExpirationDate::Days(pos_or_panic!(45.75));
478        let debug_string = format!("{expiration:?}");
479        assert_eq!(debug_string, "Days(45.75)");
480    }
481
482    #[test]
483    fn test_debug_datetime() {
484        let date = Utc.from_utc_datetime(
485            &NaiveDate::from_ymd_opt(2023, 12, 31)
486                .expect("Invalid date")
487                .and_hms_opt(23, 59, 59)
488                .expect("Invalid Time"),
489        );
490
491        let expiration = ExpirationDate::DateTime(date);
492        let debug_string = format!("{expiration:?}");
493        assert_eq!(debug_string, "DateTime(2023-12-31T23:59:59Z)");
494    }
495
496    #[cfg(feature = "clock")]
497    #[test]
498    fn test_display_past_date() {
499        let past_date = Utc::now() - Duration::days(5);
500        let expiration = ExpirationDate::DateTime(past_date);
501        let display_string = format!("{expiration}");
502        assert!(display_string.contains("UTC"));
503        assert!(display_string.contains(&past_date.format("%Y-%m-%d %H:%M:%S").to_string()));
504    }
505
506    #[cfg(feature = "clock")]
507    #[test]
508    fn test_display_today() {
509        let today = Utc::now();
510        let expiration = ExpirationDate::DateTime(today);
511        let display_string = format!("{expiration}");
512        info!("{}", display_string);
513        assert!(display_string.contains(&today.format("%Y-%m-%d %H:%M:%S").to_string()));
514    }
515}
516
517#[cfg(test)]
518mod tests_position_type_display_debug {
519    use super::*;
520    use crate::model::types::{OptionStyle, OptionType, Side};
521
522    use chrono::{DateTime, NaiveDate, TimeZone, Utc};
523    use expiration_date::ExpirationDate;
524    use positive::{Positive, pos_or_panic};
525    use rust_decimal_macros::dec;
526
527    fn get_option() -> (Options, DateTime<Utc>) {
528        let naive_date = NaiveDate::from_ymd_opt(2024, 8, 8)
529            .expect("Invalid date")
530            .and_hms_opt(0, 0, 0)
531            .expect("Invalid time");
532
533        (
534            Options {
535                option_type: OptionType::European,
536                side: Side::Long,
537                underlying_symbol: "AAPL".to_string(),
538                strike_price: pos_or_panic!(150.0),
539                expiration_date: ExpirationDate::DateTime(Utc.from_utc_datetime(&naive_date)),
540                implied_volatility: pos_or_panic!(0.25),
541                quantity: pos_or_panic!(10.0),
542                contract_size: Positive::ONE,
543                underlying_price: pos_or_panic!(155.0),
544                risk_free_rate: dec!(0.01),
545                option_style: OptionStyle::Call,
546                dividend_yield: pos_or_panic!(0.02),
547                exotic_params: None,
548            },
549            Utc.from_utc_datetime(&naive_date),
550        )
551    }
552
553    #[test]
554    fn test_position_display() {
555        let (option, naive_date) = get_option();
556        let position = Position {
557            option,
558            premium: pos_or_panic!(5.75),
559            date: naive_date,
560            open_fee: pos_or_panic!(0.50),
561            close_fee: pos_or_panic!(0.45),
562            epic: Some("Epic123".to_string()),
563            extra_fields: None,
564        };
565
566        let expected_display = "Position Details:\n\
567                Option: Long Call European Option\n\
568                Underlying: AAPL @ $155.00\n\
569                Strike: $150.00\n\
570                Expiration: 2024-08-08 00:00:00 UTC\n\
571                Implied Volatility: 25.00%\n\
572                Quantity: 10\n\
573                Risk-free Rate: 1.00%\n\
574                Dividend Yield: 2.00%\n\
575                Premium per contract: $5.75\n\
576                Date: 2024-08-08 00:00:00 UTC\n\
577                Open Fee per contract: $0.50\n\
578                Close Fee per contract: $0.45";
579
580        assert_eq!(format!("{position}"), expected_display);
581    }
582
583    #[test]
584    fn test_position_debug() {
585        let (option, naive_date) = get_option();
586
587        let position = Position {
588            option,
589            premium: pos_or_panic!(5.75),
590            date: naive_date,
591            open_fee: pos_or_panic!(0.50),
592            close_fee: pos_or_panic!(0.45),
593            epic: Some("Epic123".to_string()),
594            extra_fields: None,
595        };
596
597        let expected_debug = "Position { \
598        option: Options { \
599            option_type: European, \
600            side: Side::Long, \
601            underlying_symbol: \"AAPL\", \
602            strike_price: 150, \
603            expiration_date: DateTime(2024-08-08T00:00:00Z), \
604            implied_volatility: 0.25, \
605            quantity: 10, \
606            underlying_price: 155, \
607            risk_free_rate: 0.01, \
608            option_style: OptionStyle::Call, \
609            dividend_yield: 0.02, \
610            exotic_params: None \
611        }, \
612        premium: 5.75, \
613        date: 2024-08-08T00:00:00Z, \
614        open_fee: 0.5, \
615        close_fee: 0.45 \
616    }";
617
618        assert_eq!(format!("{position:?}"), expected_debug);
619    }
620}
621
622#[cfg(test)]
623mod tests_display_money {
624    use super::*;
625    use rust_decimal_macros::dec;
626
627    #[test]
628    fn test_display_money_finite_amount_keeps_precision() {
629        assert_eq!(format!("{:.2}", DisplayMoney(dec!(12.5))), "$12.50");
630        assert_eq!(format!("{:.2}", DisplayMoney(dec!(-3.25))), "$-3.25");
631        assert_eq!(DisplayMoney(dec!(7)).to_string(), "$7");
632        assert_eq!(
633            DisplayMoney::from(positive::pos_or_panic!(5.00)).to_string(),
634            "$5"
635        );
636    }
637
638    #[test]
639    fn test_display_money_decimal_max_renders_unlimited() {
640        assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MAX)), "Unlimited");
641        assert_eq!(DisplayMoney::from(Positive::MAX).to_string(), "Unlimited");
642    }
643
644    #[test]
645    fn test_display_money_decimal_min_renders_negative_unlimited() {
646        assert_eq!(format!("{:.2}", DisplayMoney(Decimal::MIN)), "-Unlimited");
647    }
648
649    #[test]
650    fn test_unlimited_label_only_for_sentinels() {
651        assert_eq!(unlimited_label(Decimal::MAX), Some(UNLIMITED));
652        assert_eq!(unlimited_label(Decimal::MIN), Some("-Unlimited"));
653        assert_eq!(unlimited_label(Decimal::MAX - Decimal::ONE), None);
654        assert_eq!(unlimited_label(Decimal::ZERO), None);
655    }
656}