Skip to main content

optionstratlib_core/model/
settlement.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 5/11/26
5******************************************************************************/
6
7//! What expiry, exercise or assignment obliges an option position to pay,
8//! receive or deliver. [`settlement_obligation`] documents the rules.
9
10use crate::error::SettlementError;
11use crate::model::Options;
12use crate::model::types::{OptionStyle, OptionType, Side};
13use chrono::{DateTime, Utc};
14use positive::Positive;
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17use std::fmt::Display;
18
19/// How an exercised option is settled.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
22#[serde(rename_all = "snake_case")]
23#[repr(u8)]
24pub enum SettlementType {
25    /// Only the intrinsic value changes hands, in cash.
26    Cash,
27    /// The underlying is delivered against the strike, paid in cash.
28    Physical,
29}
30
31/// What ends the option position.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
34#[serde(rename_all = "snake_case")]
35#[repr(u8)]
36pub enum SettlementEvent {
37    /// The contract reached expiry. It is exercised automatically when in the
38    /// money and lapses otherwise; applies to both sides.
39    Expiry,
40    /// The holder exercises explicitly. Long positions only.
41    Exercise,
42    /// The writer is assigned explicitly. Short positions only.
43    Assignment,
44}
45
46/// An observed settlement price of the underlying and where it came from.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
49#[serde(deny_unknown_fields)]
50pub struct SettlementPrice {
51    /// The price, per unit of the underlying.
52    pub price: Positive,
53    /// Who published the price, for example an exchange or an index name.
54    /// Carried for the caller's audit trail; it does not affect the result.
55    pub source: String,
56    /// When the price was observed.
57    pub observed_at: DateTime<Utc>,
58}
59
60/// How cash amounts are rounded: to `scale` decimal places with
61/// [`rust_decimal::RoundingStrategy::MidpointNearestEven`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
64#[serde(deny_unknown_fields)]
65pub struct CashRounding {
66    /// Decimal places kept, for example 2 for cents. A value with fewer
67    /// decimal places is left as it is.
68    pub scale: u32,
69}
70
71/// The terms under which a settlement happens.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
74#[serde(deny_unknown_fields)]
75pub struct SettlementTerms {
76    /// Cash or physical.
77    pub settlement_type: SettlementType,
78    /// What ends the position.
79    pub event: SettlementEvent,
80    /// The settlement price. See [`settlement_obligation`] for when it may be
81    /// absent.
82    pub price: Option<SettlementPrice>,
83    /// The latest instant `price` may have been observed at.
84    pub cutoff: DateTime<Utc>,
85    /// How cash amounts are rounded.
86    pub rounding: CashRounding,
87}
88
89/// What a settlement obliges a position to do, seen from that position.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
92pub struct SettlementObligation {
93    /// The option quantity the event ends, in contracts: not multiplied by the
94    /// contract size and the same for both sides.
95    pub extinguished_quantity: Positive,
96    /// Cash in account currency. Positive is received, negative is paid.
97    pub cash: Decimal,
98    /// Underlying in units of the underlying. Positive is received, negative
99    /// is delivered.
100    pub underlying: Decimal,
101}
102
103/// Computes what `terms` oblige the position in `option` to pay, receive or
104/// deliver.
105///
106/// A pure function: it reads no clock and books nothing. It reads the option
107/// type, side, call or put style, strike, quantity and contract size of
108/// `option`; the `underlying_price` and `expiration_date` are not consulted,
109/// because the price comes from `terms` and the caller decides when the event
110/// happens.
111/// Which short position gets assigned, whether an American option should be
112/// exercised early, and the ledger and margin consequences of the result all
113/// belong to the caller.
114///
115/// # Supported option types
116///
117/// [`OptionType::European`] and [`OptionType::American`]. Every other option
118/// type is rejected with [`SettlementError::UnsupportedOptionType`].
119///
120/// # Rules
121///
122/// With strike `K`, settlement price `S`, `q` contracts and multiplier `m`
123/// ([`Options::position_size`] is `q·m`):
124///
125/// | Event | Settlement | Condition | Cash | Underlying |
126/// | --- | --- | --- | --- | --- |
127/// | Expiry | either | at or out of the money | 0 | 0 |
128/// | Expiry | cash | in the money | `\|S−K\|·q·m` | 0 |
129/// | Expiry | physical | in the money | `K·q·m` | `q·m` |
130/// | Exercise or assignment | cash | in the money | `\|S−K\|·q·m` | 0 |
131/// | Exercise or assignment | cash | at or out of the money | 0 | 0 |
132/// | Exercise or assignment | physical | any moneyness | `K·q·m` | `q·m` |
133///
134/// Every row extinguishes the `q` contracts. A position is in the money when it
135/// is a call with `S > K` or a put with `S < K`; `S = K` is at the money. This
136/// differs from [`Options::is_in_the_money`], which counts `S = K` as in the
137/// money.
138///
139/// The signs say who receives, positive meaning the position receives:
140///
141/// | Position | Physical cash | Physical underlying | Cash settlement |
142/// | --- | --- | --- | --- |
143/// | Long call | pays | receives | receives |
144/// | Short call | receives | delivers | pays |
145/// | Long put | receives | delivers | receives |
146/// | Short put | pays | receives | pays |
147///
148/// An explicit event is an instruction, so a physical one moves its flows even
149/// out of the money, where a holder may still choose to exercise. A cash one
150/// never pays more than the option's intrinsic value, which is never negative:
151/// out of the money it extinguishes the contracts with no cashflow.
152///
153/// # Settlement price
154///
155/// A supplied price must have been observed at or before the cutoff, whatever
156/// the event. An expiry needs a price to decide moneyness and a cash settlement
157/// needs one to size its cashflow, so both fail without it. A physical explicit
158/// exercise or assignment does not depend on the price, since strike and size
159/// fix both flows, so it needs none.
160///
161/// # Rounding
162///
163/// `cash` is computed exactly from `S`, `K`, `q` and `m`, and rounded once at
164/// the end, to [`CashRounding::scale`] decimal places with
165/// [`rust_decimal::RoundingStrategy::MidpointNearestEven`] (banker's rounding).
166/// No intermediate product is rounded, so it cannot create or break a tie. The
167/// rounding is applied to the magnitude before the sign, so the two sides of a
168/// contract get exactly opposite amounts. A value with no more decimal places
169/// than the scale is left as it is. A `Decimal` holds at most 28 decimal places
170/// and 96 bits, so a larger scale, or an amount too large for the scale, keeps
171/// as many places as fit, still rounded from the exact value.
172///
173/// `underlying` is [`Options::position_size`], `q·m` at `Decimal` precision, with
174/// no [`CashRounding`] applied. `extinguished_quantity` is `q` as given.
175///
176/// # Errors
177///
178/// - [`SettlementError::UnsupportedOptionType`] when `option` is neither European
179///   nor American.
180/// - [`SettlementError::EventSideMismatch`] for an exercise of a short
181///   position or an assignment of a long one.
182/// - [`SettlementError::PriceAfterCutoff`] when a supplied price was observed
183///   after `terms.cutoff`.
184/// - [`SettlementError::MissingPrice`] for an expiry or a cash settlement with
185///   no price.
186/// - [`SettlementError::ArithmeticOverflow`] when the position size or the
187///   integer part of a cash amount leaves the representable range.
188///
189/// # Examples
190///
191/// ```rust
192/// use chrono::{TimeZone, Utc};
193/// use optionstratlib_core::model::types::{OptionStyle, OptionType, Side};
194/// use optionstratlib_core::model::{
195///     CashRounding, ExpirationDate, Options, Positive, SettlementEvent, SettlementPrice,
196///     SettlementTerms, SettlementType, settlement_obligation,
197/// };
198/// use optionstratlib_core::pos_or_panic;
199/// use rust_decimal_macros::dec;
200///
201/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
202/// let option = Options::new(
203///     OptionType::European,
204///     Side::Long,
205///     "AAPL".to_string(),
206///     pos_or_panic!(100.0),
207///     ExpirationDate::Days(pos_or_panic!(30.0)),
208///     pos_or_panic!(0.2),
209///     pos_or_panic!(2.0),
210///     pos_or_panic!(100.0),
211///     dec!(0.05),
212///     OptionStyle::Call,
213///     Positive::ZERO,
214///     None,
215/// )
216/// .with_contract_size(Positive::HUNDRED);
217/// let cutoff = Utc
218///     .timestamp_opt(1_700_000_000, 0)
219///     .single()
220///     .ok_or("not a valid instant")?;
221/// let terms = SettlementTerms {
222///     settlement_type: SettlementType::Physical,
223///     event: SettlementEvent::Expiry,
224///     price: Some(SettlementPrice {
225///         price: pos_or_panic!(110.0),
226///         source: "exchange close".to_string(),
227///         observed_at: cutoff,
228///     }),
229///     cutoff,
230///     rounding: CashRounding { scale: 2 },
231/// };
232///
233/// let obligation = settlement_obligation(&option, &terms)?;
234/// assert_eq!(obligation.extinguished_quantity, pos_or_panic!(2.0));
235/// assert_eq!(obligation.underlying, dec!(200));
236/// assert_eq!(obligation.cash, dec!(-20000));
237/// # Ok(())
238/// # }
239/// ```
240#[must_use = "the settlement obligation is the only output of this computation"]
241pub fn settlement_obligation(
242    option: &Options,
243    terms: &SettlementTerms,
244) -> Result<SettlementObligation, SettlementError> {
245    check_option_type(&option.option_type)?;
246    check_event_side(terms.event, option.side)?;
247    let intrinsic = match observed_price(terms)? {
248        Some(price) => Some(intrinsic_per_unit(
249            option.option_style,
250            option.strike_price,
251            price,
252        )?),
253        None => None,
254    };
255    let size = option
256        .position_size()
257        .map_err(|error| overflow("position size", error))?
258        .to_dec();
259    let quantity = option.quantity.to_dec();
260    let multiplier = option.contract_size.to_dec();
261
262    let (cash, underlying) = match (terms.settlement_type, terms.event, intrinsic) {
263        (SettlementType::Cash, _, None)
264        | (SettlementType::Physical, SettlementEvent::Expiry, None) => {
265            return Err(SettlementError::MissingPrice {
266                settlement_type: terms.settlement_type,
267                event: terms.event,
268            });
269        }
270        (SettlementType::Cash, _, Some(intrinsic)) => {
271            let amount = rounded_amount(
272                "cash settlement",
273                intrinsic,
274                quantity,
275                multiplier,
276                terms.rounding,
277            )?;
278            (signed(amount, option.side == Side::Long), Decimal::ZERO)
279        }
280        (SettlementType::Physical, SettlementEvent::Expiry, Some(intrinsic))
281            if intrinsic.is_zero() =>
282        {
283            (Decimal::ZERO, Decimal::ZERO)
284        }
285        (SettlementType::Physical, _, _) => {
286            let strike = ExactDecimal::from_decimal(option.strike_price.to_dec())
287                .ok_or_else(|| overflow("physical cash", "the strike is out of range"))?;
288            let amount = rounded_amount(
289                "physical cash",
290                strike,
291                quantity,
292                multiplier,
293                terms.rounding,
294            )?;
295            let receives_underlying = matches!(
296                (option.side, option.option_style),
297                (Side::Long, OptionStyle::Call) | (Side::Short, OptionStyle::Put)
298            );
299            (
300                signed(amount, !receives_underlying),
301                signed(size, receives_underlying),
302            )
303        }
304    };
305
306    Ok(SettlementObligation {
307        extinguished_quantity: option.quantity,
308        cash,
309        underlying,
310    })
311}
312
313/// Rejects every option type with no settlement rule.
314fn check_option_type(option_type: &OptionType) -> Result<(), SettlementError> {
315    match option_type {
316        OptionType::European | OptionType::American => Ok(()),
317        unsupported => Err(unsupported_option_type(unsupported)),
318    }
319}
320
321/// Rejects an exercise of a short position and an assignment of a long one.
322fn check_event_side(event: SettlementEvent, side: Side) -> Result<(), SettlementError> {
323    match (event, side) {
324        (SettlementEvent::Exercise, Side::Short) | (SettlementEvent::Assignment, Side::Long) => {
325            Err(SettlementError::EventSideMismatch { event, side })
326        }
327        _ => Ok(()),
328    }
329}
330
331/// The supplied price, once it is known to respect the cutoff.
332fn observed_price(terms: &SettlementTerms) -> Result<Option<Positive>, SettlementError> {
333    match &terms.price {
334        Some(price) if price.observed_at > terms.cutoff => Err(SettlementError::PriceAfterCutoff {
335            observed_at: price.observed_at,
336            cutoff: terms.cutoff,
337        }),
338        Some(price) => Ok(Some(price.price)),
339        None => Ok(None),
340    }
341}
342
343/// The value of one unit of the option at `price`, exactly: `S−K` for a call
344/// and `K−S` for a put, floored at zero. Zero means at or out of the money.
345fn intrinsic_per_unit(
346    style: OptionStyle,
347    strike: Positive,
348    price: Positive,
349) -> Result<ExactDecimal, SettlementError> {
350    let (minuend, subtrahend) = match style {
351        OptionStyle::Call => (price.to_dec(), strike.to_dec()),
352        OptionStyle::Put => (strike.to_dec(), price.to_dec()),
353    };
354    let scale = minuend.scale().max(subtrahend.scale());
355    let aligned = |value: Decimal| {
356        (value.scale()..scale).try_fold(
357            ExactUint::from_u128(value.mantissa().unsigned_abs())?,
358            |digits, _| digits.checked_mul_u32(10),
359        )
360    };
361    let difference = aligned(minuend).zip(aligned(subtrahend)).ok_or_else(|| {
362        overflow(
363            "intrinsic value",
364            format!("{minuend} minus {subtrahend} is out of range"),
365        )
366    })?;
367    Ok(ExactDecimal {
368        digits: difference
369            .0
370            .checked_sub(difference.1)
371            .unwrap_or(ExactUint::ZERO),
372        scale,
373    })
374}
375
376/// `amount × quantity × multiplier`, computed exactly and rounded once by
377/// `rounding`.
378fn rounded_amount(
379    operation: &'static str,
380    amount: ExactDecimal,
381    quantity: Decimal,
382    multiplier: Decimal,
383    rounding: CashRounding,
384) -> Result<Decimal, SettlementError> {
385    let out_of_range = || {
386        overflow(
387            operation,
388            format!("{quantity} times {multiplier} leaves the range"),
389        )
390    };
391    let digits = amount
392        .digits
393        .checked_mul_u128(quantity.mantissa().unsigned_abs())
394        .and_then(|digits| digits.checked_mul_u128(multiplier.mantissa().unsigned_abs()))
395        .ok_or_else(out_of_range)?;
396    let scale = amount
397        .scale
398        .checked_add(quantity.scale())
399        .and_then(|scale| scale.checked_add(multiplier.scale()))
400        .ok_or_else(out_of_range)?;
401    ExactDecimal { digits, scale }
402        .rounded(rounding.scale)
403        .ok_or_else(out_of_range)
404}
405
406/// Limbs of an [`ExactUint`]: 384 bits hold every amount computed here. An
407/// intrinsic value aligned to scale 28 is below `2^190`, and the mantissas of
408/// the quantity and the multiplier are each below `2^96`.
409const EXACT_LIMBS: usize = 12;
410
411/// The bits of one limb of an [`ExactUint`].
412const LIMB_BITS: u32 = u32::BITS;
413
414/// Selects the low limb of a `u128`.
415const LIMB_MASK: u128 = 0xFFFF_FFFF;
416
417/// The most decimal places a `Decimal` holds.
418const MAX_DECIMAL_SCALE: u32 = 28;
419
420/// An unsigned integer wide enough to hold a settlement amount without rounding,
421/// least significant limb first. Every operation returns `None` on overflow
422/// rather than truncating, which the bound on [`EXACT_LIMBS`] rules out.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424struct ExactUint([u32; EXACT_LIMBS]);
425
426impl ExactUint {
427    const ZERO: Self = Self([0; EXACT_LIMBS]);
428
429    fn from_u128(value: u128) -> Option<Self> {
430        let mut limbs = [0; EXACT_LIMBS];
431        let mut rest = value;
432        for limb in limbs.iter_mut().take(4) {
433            *limb = u32::try_from(rest & LIMB_MASK).ok()?;
434            rest = rest.checked_shr(LIMB_BITS)?;
435        }
436        Some(Self(limbs))
437    }
438
439    /// The value, when it fits in 128 bits.
440    fn to_u128(self) -> Option<u128> {
441        if self.0.iter().skip(4).any(|limb| *limb != 0) {
442            return None;
443        }
444        self.0.iter().take(4).rev().try_fold(0_u128, |value, limb| {
445            Some(value.checked_shl(LIMB_BITS)? | u128::from(*limb))
446        })
447    }
448
449    fn is_zero(self) -> bool {
450        self == Self::ZERO
451    }
452
453    fn is_odd(self) -> bool {
454        self.0.first().is_some_and(|limb| limb & 1 == 1)
455    }
456
457    fn checked_add(self, other: Self) -> Option<Self> {
458        let mut limbs = self.0;
459        let mut carry = 0_u64;
460        for (limb, addend) in limbs.iter_mut().zip(other.0) {
461            let sum = u64::from(*limb)
462                .checked_add(u64::from(addend))?
463                .checked_add(carry)?;
464            (*limb, carry) = split_limb(sum)?;
465        }
466        (carry == 0).then_some(Self(limbs))
467    }
468
469    /// `None` when `other` is larger.
470    fn checked_sub(self, other: Self) -> Option<Self> {
471        let mut limbs = self.0;
472        let mut borrow = false;
473        for (limb, subtrahend) in limbs.iter_mut().zip(other.0) {
474            let (partial, first) = limb.overflowing_sub(subtrahend);
475            let (difference, second) = partial.overflowing_sub(u32::from(borrow));
476            *limb = difference;
477            borrow = first || second;
478        }
479        (!borrow).then_some(Self(limbs))
480    }
481
482    fn checked_mul_u32(self, factor: u32) -> Option<Self> {
483        let mut limbs = self.0;
484        let mut carry = 0_u64;
485        for limb in &mut limbs {
486            let product = u64::from(*limb)
487                .checked_mul(u64::from(factor))?
488                .checked_add(carry)?;
489            (*limb, carry) = split_limb(product)?;
490        }
491        (carry == 0).then_some(Self(limbs))
492    }
493
494    /// Schoolbook multiplication, from the most significant limb of `factor`.
495    fn checked_mul_u128(self, factor: u128) -> Option<Self> {
496        let factor = Self::from_u128(factor)?;
497        factor
498            .0
499            .iter()
500            .take(4)
501            .rev()
502            .try_fold(Self::ZERO, |product, limb| {
503                product
504                    .shifted_one_limb()?
505                    .checked_add(self.checked_mul_u32(*limb)?)
506            })
507    }
508
509    fn shifted_one_limb(self) -> Option<Self> {
510        let mut limbs = self.0;
511        limbs.rotate_right(1);
512        (limbs.first() == Some(&0)).then_some(Self(limbs))
513    }
514
515    /// The quotient and the remainder of a division by ten.
516    fn divided_by_ten(self) -> Option<(Self, u32)> {
517        let mut limbs = self.0;
518        let mut remainder = 0_u64;
519        for limb in limbs.iter_mut().rev() {
520            let dividend = remainder.checked_shl(LIMB_BITS)? | u64::from(*limb);
521            *limb = u32::try_from(dividend.checked_div(10)?).ok()?;
522            remainder = dividend.checked_rem(10)?;
523        }
524        Some((Self(limbs), u32::try_from(remainder).ok()?))
525    }
526}
527
528/// The low limb of `value` and what carries into the next one.
529#[inline]
530fn split_limb(value: u64) -> Option<(u32, u64)> {
531    let low = u32::try_from(value & u64::from(u32::MAX)).ok()?;
532    Some((low, value.checked_shr(LIMB_BITS)?))
533}
534
535/// An exact non-negative decimal: `digits / 10^scale`.
536#[derive(Debug, Clone, Copy)]
537struct ExactDecimal {
538    digits: ExactUint,
539    scale: u32,
540}
541
542impl ExactDecimal {
543    fn from_decimal(value: Decimal) -> Option<Self> {
544        Some(Self {
545            digits: ExactUint::from_u128(value.mantissa().unsigned_abs())?,
546            scale: value.scale(),
547        })
548    }
549
550    fn is_zero(self) -> bool {
551        self.digits.is_zero()
552    }
553
554    /// The value rounded half to even at `scale` decimal places, or at as many
555    /// as a `Decimal` can hold, or `None` when its integer part does not fit.
556    fn rounded(self, scale: u32) -> Option<Decimal> {
557        let target = scale.min(MAX_DECIMAL_SCALE);
558        let mut digits = self.digits;
559        let mut current = self.scale;
560        // The most significant digit dropped, and whether anything below it was
561        // not zero: what tells a tie from a value on either side of it.
562        let mut dropped = 0;
563        let mut below_dropped = false;
564        loop {
565            if current <= target {
566                let rounds_up = dropped > 5 || (dropped == 5 && (below_dropped || digits.is_odd()));
567                let rounded = if rounds_up {
568                    digits.checked_add(ExactUint::from_u128(1)?)?
569                } else {
570                    digits
571                };
572                let value = rounded
573                    .to_u128()
574                    .and_then(|mantissa| i128::try_from(mantissa).ok())
575                    .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, current).ok());
576                if value.is_some() {
577                    return value;
578                }
579                if current == 0 {
580                    return None;
581                }
582            }
583            let (quotient, digit) = digits.divided_by_ten()?;
584            below_dropped = below_dropped || dropped != 0;
585            dropped = digit;
586            digits = quotient;
587            current = current.checked_sub(1)?;
588        }
589    }
590}
591
592/// `magnitude` with the sign of a flow the position receives or pays. A zero
593/// stays unsigned, so a side with nothing to pay never shows `-0`.
594#[inline]
595fn signed(magnitude: Decimal, received: bool) -> Decimal {
596    if received || magnitude.is_zero() {
597        magnitude
598    } else {
599        -magnitude
600    }
601}
602
603#[cold]
604#[inline(never)]
605fn unsupported_option_type(option_type: &OptionType) -> SettlementError {
606    SettlementError::UnsupportedOptionType {
607        option_type: option_type.clone(),
608    }
609}
610
611#[cold]
612#[inline(never)]
613fn overflow(operation: &'static str, reason: impl Display) -> SettlementError {
614    SettlementError::ArithmeticOverflow {
615        operation,
616        reason: reason.to_string(),
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::model::ExpirationDate;
624    use crate::model::types::{AsianAveragingType, BarrierType};
625    use chrono::TimeDelta;
626    use rust_decimal_macros::dec;
627    use serde::de::DeserializeOwned;
628
629    const STRIKE: Decimal = dec!(100);
630    const IN_THE_MONEY_CALL: Decimal = dec!(110);
631    const IN_THE_MONEY_PUT: Decimal = dec!(90);
632    /// The contracts of every fixture.
633    const QUANTITY: Decimal = dec!(2);
634    /// The multiplier of every fixture.
635    const MULTIPLIER: Decimal = dec!(100);
636    /// `QUANTITY × MULTIPLIER`.
637    const SIZE: Decimal = dec!(200);
638    /// `|S−K| × SIZE`, the same at both in-the-money prices.
639    const CASH_AMOUNT: Decimal = dec!(2000);
640    /// `STRIKE × SIZE`.
641    const PHYSICAL_CASH: Decimal = dec!(20000);
642
643    fn pos(value: Decimal) -> Positive {
644        Positive::new_decimal(value).unwrap_or(Positive::ZERO)
645    }
646
647    fn cutoff() -> DateTime<Utc> {
648        DateTime::<Utc>::UNIX_EPOCH + TimeDelta::days(20_000)
649    }
650
651    fn contract(
652        option_type: OptionType,
653        side: Side,
654        style: OptionStyle,
655        strike: Decimal,
656        quantity: Decimal,
657        multiplier: Decimal,
658    ) -> Options {
659        Options::new(
660            option_type,
661            side,
662            "TEST".to_string(),
663            pos(strike),
664            ExpirationDate::Days(Positive::ONE),
665            pos(dec!(0.2)),
666            pos(quantity),
667            pos(strike),
668            dec!(0.05),
669            style,
670            Positive::ZERO,
671            None,
672        )
673        .with_contract_size(pos(multiplier))
674    }
675
676    fn fixture(side: Side, style: OptionStyle) -> Options {
677        contract(
678            OptionType::European,
679            side,
680            style,
681            STRIKE,
682            QUANTITY,
683            MULTIPLIER,
684        )
685    }
686
687    fn observed(price: Decimal) -> SettlementPrice {
688        SettlementPrice {
689            price: pos(price),
690            source: "test close".to_string(),
691            observed_at: cutoff(),
692        }
693    }
694
695    fn terms(
696        settlement_type: SettlementType,
697        event: SettlementEvent,
698        price: Option<Decimal>,
699    ) -> SettlementTerms {
700        SettlementTerms {
701            settlement_type,
702            event,
703            price: price.map(observed),
704            cutoff: cutoff(),
705            rounding: CashRounding { scale: 2 },
706        }
707    }
708
709    fn itm_price(style: OptionStyle) -> Decimal {
710        match style {
711            OptionStyle::Call => IN_THE_MONEY_CALL,
712            OptionStyle::Put => IN_THE_MONEY_PUT,
713        }
714    }
715
716    fn otm_price(style: OptionStyle) -> Decimal {
717        match style {
718            OptionStyle::Call => IN_THE_MONEY_PUT,
719            OptionStyle::Put => IN_THE_MONEY_CALL,
720        }
721    }
722
723    /// The event that executes a position on `side` explicitly.
724    fn explicit_event(side: Side) -> SettlementEvent {
725        match side {
726            Side::Long => SettlementEvent::Exercise,
727            Side::Short => SettlementEvent::Assignment,
728        }
729    }
730
731    fn obligation(cash: Decimal, underlying: Decimal) -> SettlementObligation {
732        SettlementObligation {
733            extinguished_quantity: pos(QUANTITY),
734            cash,
735            underlying,
736        }
737    }
738
739    /// The flows of a physical execution, from the table in `settlement_obligation`.
740    fn physical_flows(side: Side, style: OptionStyle) -> SettlementObligation {
741        match (side, style) {
742            (Side::Long, OptionStyle::Call) => obligation(-PHYSICAL_CASH, SIZE),
743            (Side::Short, OptionStyle::Call) => obligation(PHYSICAL_CASH, -SIZE),
744            (Side::Long, OptionStyle::Put) => obligation(PHYSICAL_CASH, -SIZE),
745            (Side::Short, OptionStyle::Put) => obligation(-PHYSICAL_CASH, SIZE),
746        }
747    }
748
749    /// The flow of a cash execution in the money.
750    fn cash_flows(side: Side) -> SettlementObligation {
751        match side {
752            Side::Long => obligation(CASH_AMOUNT, Decimal::ZERO),
753            Side::Short => obligation(-CASH_AMOUNT, Decimal::ZERO),
754        }
755    }
756
757    const SIDES: [Side; 2] = [Side::Long, Side::Short];
758    const STYLES: [OptionStyle; 2] = [OptionStyle::Call, OptionStyle::Put];
759
760    fn each_position(check: impl Fn(Side, OptionStyle)) {
761        for side in SIDES {
762            for style in STYLES {
763                check(side, style);
764            }
765        }
766    }
767
768    fn settle(side: Side, style: OptionStyle, terms: &SettlementTerms) -> SettlementObligation {
769        match settlement_obligation(&fixture(side, style), terms) {
770            Ok(settled) => settled,
771            Err(error) => panic!("{side:?} {style:?}: {error}"),
772        }
773    }
774
775    #[test]
776    fn test_settlement_expiry_cash_itm_pays_intrinsic_value() {
777        each_position(|side, style| {
778            let terms = terms(
779                SettlementType::Cash,
780                SettlementEvent::Expiry,
781                Some(itm_price(style)),
782            );
783            assert_eq!(settle(side, style, &terms), cash_flows(side));
784        });
785    }
786
787    #[test]
788    fn test_settlement_expiry_physical_itm_moves_underlying_and_cash() {
789        each_position(|side, style| {
790            let terms = terms(
791                SettlementType::Physical,
792                SettlementEvent::Expiry,
793                Some(itm_price(style)),
794            );
795            assert_eq!(settle(side, style, &terms), physical_flows(side, style));
796        });
797    }
798
799    #[test]
800    fn test_settlement_expiry_atm_extinguishes_with_no_flows() {
801        each_position(|side, style| {
802            for settlement_type in [SettlementType::Cash, SettlementType::Physical] {
803                let terms = terms(settlement_type, SettlementEvent::Expiry, Some(STRIKE));
804                assert_eq!(
805                    settle(side, style, &terms),
806                    obligation(Decimal::ZERO, Decimal::ZERO)
807                );
808            }
809        });
810    }
811
812    #[test]
813    fn test_settlement_expiry_otm_extinguishes_with_no_flows() {
814        each_position(|side, style| {
815            for settlement_type in [SettlementType::Cash, SettlementType::Physical] {
816                let terms = terms(
817                    settlement_type,
818                    SettlementEvent::Expiry,
819                    Some(otm_price(style)),
820                );
821                assert_eq!(
822                    settle(side, style, &terms),
823                    obligation(Decimal::ZERO, Decimal::ZERO)
824                );
825            }
826        });
827    }
828
829    #[test]
830    fn test_settlement_explicit_cash_itm_pays_intrinsic_value() {
831        each_position(|side, style| {
832            let terms = terms(
833                SettlementType::Cash,
834                explicit_event(side),
835                Some(itm_price(style)),
836            );
837            assert_eq!(settle(side, style, &terms), cash_flows(side));
838        });
839    }
840
841    #[test]
842    fn test_settlement_explicit_cash_atm_has_no_cashflow() {
843        each_position(|side, style| {
844            let terms = terms(SettlementType::Cash, explicit_event(side), Some(STRIKE));
845            assert_eq!(
846                settle(side, style, &terms),
847                obligation(Decimal::ZERO, Decimal::ZERO)
848            );
849        });
850    }
851
852    #[test]
853    fn test_settlement_explicit_cash_otm_has_no_cashflow() {
854        each_position(|side, style| {
855            let terms = terms(
856                SettlementType::Cash,
857                explicit_event(side),
858                Some(otm_price(style)),
859            );
860            assert_eq!(
861                settle(side, style, &terms),
862                obligation(Decimal::ZERO, Decimal::ZERO)
863            );
864        });
865    }
866
867    #[test]
868    fn test_settlement_explicit_physical_executes_at_any_moneyness() {
869        each_position(|side, style| {
870            for price in [itm_price(style), STRIKE, otm_price(style)] {
871                let terms = terms(SettlementType::Physical, explicit_event(side), Some(price));
872                assert_eq!(settle(side, style, &terms), physical_flows(side, style));
873            }
874        });
875    }
876
877    #[test]
878    fn test_settlement_explicit_physical_without_price_executes() {
879        each_position(|side, style| {
880            let terms = terms(SettlementType::Physical, explicit_event(side), None);
881            assert_eq!(settle(side, style, &terms), physical_flows(side, style));
882        });
883    }
884
885    #[test]
886    fn test_settlement_multiplier_scales_flows_not_extinguished_quantity() {
887        let option = contract(
888            OptionType::American,
889            Side::Long,
890            OptionStyle::Call,
891            STRIKE,
892            dec!(3),
893            dec!(10),
894        );
895        let physical = terms(
896            SettlementType::Physical,
897            SettlementEvent::Exercise,
898            Some(IN_THE_MONEY_CALL),
899        );
900        assert_eq!(
901            settlement_obligation(&option, &physical),
902            Ok(SettlementObligation {
903                extinguished_quantity: pos(dec!(3)),
904                cash: dec!(-3000),
905                underlying: dec!(30),
906            })
907        );
908        let cash = terms(
909            SettlementType::Cash,
910            SettlementEvent::Exercise,
911            Some(IN_THE_MONEY_CALL),
912        );
913        assert_eq!(
914            settlement_obligation(&option, &cash),
915            Ok(SettlementObligation {
916                extinguished_quantity: pos(dec!(3)),
917                cash: dec!(300),
918                underlying: Decimal::ZERO,
919            })
920        );
921    }
922
923    #[test]
924    fn test_settlement_unit_multiplier_is_the_quantity() {
925        let option = contract(
926            OptionType::European,
927            Side::Short,
928            OptionStyle::Put,
929            STRIKE,
930            dec!(5),
931            Decimal::ONE,
932        );
933        let terms = terms(SettlementType::Physical, SettlementEvent::Assignment, None);
934        assert_eq!(
935            settlement_obligation(&option, &terms),
936            Ok(SettlementObligation {
937                extinguished_quantity: pos(dec!(5)),
938                cash: dec!(-500),
939                underlying: dec!(5),
940            })
941        );
942    }
943
944    #[test]
945    fn test_settlement_cash_rounding_midpoint_goes_to_even() {
946        for (price, expected) in [
947            (dec!(100.125), dec!(0.12)),
948            (dec!(100.135), dec!(0.14)),
949            (dec!(100.126), dec!(0.13)),
950        ] {
951            for (side, sign) in [
952                (Side::Long, Decimal::ONE),
953                (Side::Short, Decimal::NEGATIVE_ONE),
954            ] {
955                let option = contract(
956                    OptionType::European,
957                    side,
958                    OptionStyle::Call,
959                    STRIKE,
960                    Decimal::ONE,
961                    Decimal::ONE,
962                );
963                let terms = terms(SettlementType::Cash, SettlementEvent::Expiry, Some(price));
964                assert_eq!(
965                    settlement_obligation(&option, &terms).map(|o| o.cash),
966                    Ok(expected * sign)
967                );
968            }
969        }
970    }
971
972    #[test]
973    fn test_settlement_physical_cash_rounds_the_exact_triple_product() {
974        // q·m is 1 − 1e-30, which a Decimal rounds to 1: going through it would
975        // turn 101.01499…9 into the tie 101.015, and that tie into 101.02.
976        let option = contract(
977            OptionType::European,
978            Side::Long,
979            OptionStyle::Call,
980            dec!(101.015),
981            dec!(1.000000000000001),
982            dec!(0.999999999999999),
983        );
984        let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
985
986        assert!(matches!(
987            settlement_obligation(&option, &terms),
988            Ok(SettlementObligation { cash, .. }) if cash == dec!(-101.01)
989        ));
990    }
991
992    #[test]
993    fn test_settlement_cash_settlement_rounds_the_exact_triple_product() {
994        // The same false tie through the intrinsic value: 1.015 × (1 − 1e-30).
995        let option = contract(
996            OptionType::European,
997            Side::Long,
998            OptionStyle::Call,
999            dec!(100),
1000            dec!(1.000000000000001),
1001            dec!(0.999999999999999),
1002        );
1003        let terms = terms(
1004            SettlementType::Cash,
1005            SettlementEvent::Expiry,
1006            Some(dec!(101.015)),
1007        );
1008
1009        assert!(matches!(
1010            settlement_obligation(&option, &terms),
1011            Ok(SettlementObligation { cash, .. }) if cash == dec!(1.01)
1012        ));
1013    }
1014
1015    #[test]
1016    fn test_settlement_cash_rounds_the_exact_intrinsic_value() {
1017        // S − K is 1000000000000.13499…9, which a Decimal rounds to the tie
1018        // 1000000000000.135, and that tie to .14.
1019        let option = contract(
1020            OptionType::European,
1021            Side::Long,
1022            OptionStyle::Call,
1023            dec!(0.0000000000000000000000000001),
1024            dec!(1),
1025            dec!(1),
1026        );
1027        let terms = terms(
1028            SettlementType::Cash,
1029            SettlementEvent::Expiry,
1030            Some(dec!(1000000000000.135)),
1031        );
1032
1033        assert!(matches!(
1034            settlement_obligation(&option, &terms),
1035            Ok(SettlementObligation { cash, .. }) if cash == dec!(1000000000000.13)
1036        ));
1037    }
1038
1039    #[test]
1040    fn test_settlement_expiry_keeps_an_exact_intrinsic_value_above_zero() {
1041        // S − K is 1e-28 at a scale a Decimal holds, and it is in the money.
1042        let option = contract(
1043            OptionType::European,
1044            Side::Long,
1045            OptionStyle::Call,
1046            dec!(1),
1047            dec!(1),
1048            dec!(1),
1049        );
1050        let terms = terms(
1051            SettlementType::Physical,
1052            SettlementEvent::Expiry,
1053            Some(dec!(1.0000000000000000000000000001)),
1054        );
1055
1056        assert!(matches!(
1057            settlement_obligation(&option, &terms),
1058            Ok(SettlementObligation { cash, underlying, .. })
1059                if cash == dec!(-1) && underlying == dec!(1)
1060        ));
1061    }
1062
1063    #[test]
1064    fn test_settlement_rounding_a_true_tie_still_goes_to_even() {
1065        for (strike, expected) in [
1066            (dec!(101.015), dec!(-101.02)),
1067            (dec!(101.025), dec!(-101.02)),
1068        ] {
1069            let option = contract(
1070                OptionType::European,
1071                Side::Long,
1072                OptionStyle::Call,
1073                strike,
1074                dec!(1),
1075                dec!(1),
1076            );
1077            let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1078
1079            assert!(matches!(
1080                settlement_obligation(&option, &terms),
1081                Ok(SettlementObligation { cash, .. }) if cash == expected
1082            ));
1083        }
1084    }
1085
1086    #[test]
1087    fn test_settlement_rounding_scale_above_decimal_precision_keeps_what_fits() {
1088        // The exact product has 30 decimal places, and a Decimal holds 28.
1089        let option = contract(
1090            OptionType::European,
1091            Side::Long,
1092            OptionStyle::Call,
1093            dec!(1),
1094            dec!(1.000000000000001),
1095            dec!(0.999999999999999),
1096        );
1097        let mut terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1098        terms.rounding = CashRounding { scale: 40 };
1099
1100        assert!(matches!(
1101            settlement_obligation(&option, &terms),
1102            Ok(SettlementObligation { cash, .. }) if cash == dec!(-1)
1103        ));
1104    }
1105
1106    #[test]
1107    fn test_settlement_large_amount_keeps_the_places_that_fit() {
1108        // 8e18 × 1.0000000001 at scale 10 needs more than 96 bits; at scale 9
1109        // it fits, rounded from the exact value.
1110        let option = contract(
1111            OptionType::European,
1112            Side::Long,
1113            OptionStyle::Call,
1114            dec!(8000000000000000000),
1115            dec!(1.0000000001),
1116            dec!(1),
1117        );
1118        let mut terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1119        terms.rounding = CashRounding { scale: 28 };
1120
1121        assert!(matches!(
1122            settlement_obligation(&option, &terms),
1123            Ok(SettlementObligation { cash, .. }) if cash == dec!(-8000000000800000000)
1124        ));
1125    }
1126
1127    /// `ExactUint::from_u128` for a test: every `u128` fits, so a zero here can
1128    /// only fail the comparison that follows.
1129    fn exact(value: u128) -> ExactUint {
1130        ExactUint::from_u128(value).unwrap_or(ExactUint::ZERO)
1131    }
1132
1133    #[test]
1134    fn test_exact_uint_arithmetic_matches_u128() {
1135        let left: u128 = 0xFFFF_FFFF_FFFF_FFFF_FFFF_FFFF;
1136        let right: u128 = 0xFFFF_FFFF;
1137        assert_eq!(exact(u128::MAX).to_u128(), Some(u128::MAX));
1138        assert_eq!(
1139            exact(left)
1140                .checked_mul_u128(right)
1141                .and_then(ExactUint::to_u128),
1142            left.checked_mul(right)
1143        );
1144        assert_eq!(
1145            exact(left)
1146                .checked_add(exact(right))
1147                .and_then(ExactUint::to_u128),
1148            left.checked_add(right)
1149        );
1150        assert_eq!(
1151            exact(left)
1152                .checked_sub(exact(right))
1153                .and_then(ExactUint::to_u128),
1154            left.checked_sub(right)
1155        );
1156        assert_eq!(exact(right).checked_sub(exact(left)), None);
1157        assert_eq!(
1158            exact(left)
1159                .divided_by_ten()
1160                .and_then(|(quotient, _)| quotient.to_u128()),
1161            Some(left / 10)
1162        );
1163        assert_eq!(
1164            exact(left)
1165                .divided_by_ten()
1166                .map(|(_, remainder)| u128::from(remainder)),
1167            Some(left % 10)
1168        );
1169    }
1170
1171    #[test]
1172    fn test_exact_uint_holds_the_largest_amount_and_refuses_more() {
1173        // The bound behind EXACT_LIMBS: below 2^190 × 2^96 × 2^96.
1174        let mantissa: u128 = (1 << 96) - 1;
1175        let largest = (0..28)
1176            .try_fold(exact(mantissa), |digits, _| digits.checked_mul_u32(10))
1177            .and_then(|digits| digits.checked_mul_u128(mantissa))
1178            .and_then(|digits| digits.checked_mul_u128(mantissa));
1179        assert!(largest.is_some());
1180        assert_eq!(
1181            largest.and_then(|digits| digits.checked_mul_u128(1 << 96)),
1182            None
1183        );
1184    }
1185
1186    #[test]
1187    fn test_settlement_rounding_applies_to_physical_cash_only() {
1188        let option = contract(
1189            OptionType::European,
1190            Side::Long,
1191            OptionStyle::Call,
1192            dec!(33.335),
1193            dec!(0.125),
1194            dec!(8),
1195        );
1196        let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1197        assert_eq!(
1198            settlement_obligation(&option, &terms),
1199            Ok(SettlementObligation {
1200                extinguished_quantity: pos(dec!(0.125)),
1201                cash: dec!(-33.34),
1202                underlying: Decimal::ONE,
1203            })
1204        );
1205    }
1206
1207    #[test]
1208    fn test_settlement_rounding_scale_decides_the_decimal_places() {
1209        let option = contract(
1210            OptionType::European,
1211            Side::Long,
1212            OptionStyle::Call,
1213            STRIKE,
1214            Decimal::ONE,
1215            Decimal::ONE,
1216        );
1217        let mut terms = terms(
1218            SettlementType::Cash,
1219            SettlementEvent::Expiry,
1220            Some(dec!(101.5)),
1221        );
1222        terms.rounding = CashRounding { scale: 0 };
1223        assert_eq!(
1224            settlement_obligation(&option, &terms).map(|o| o.cash),
1225            Ok(dec!(2))
1226        );
1227        terms.rounding = CashRounding { scale: 40 };
1228        assert_eq!(
1229            settlement_obligation(&option, &terms).map(|o| o.cash),
1230            Ok(dec!(1.5))
1231        );
1232    }
1233
1234    #[test]
1235    fn test_settlement_no_flow_is_never_negative_zero() {
1236        let terms = terms(SettlementType::Cash, SettlementEvent::Expiry, Some(STRIKE));
1237        let result = settlement_obligation(&fixture(Side::Short, OptionStyle::Call), &terms);
1238        assert!(matches!(
1239            result,
1240            Ok(SettlementObligation { cash, underlying, .. })
1241                if !cash.is_sign_negative() && !underlying.is_sign_negative()
1242        ));
1243    }
1244
1245    #[test]
1246    fn test_settlement_cash_rounded_to_zero_is_not_negative_zero() {
1247        let option = contract(
1248            OptionType::European,
1249            Side::Short,
1250            OptionStyle::Call,
1251            STRIKE,
1252            Decimal::ONE,
1253            Decimal::ONE,
1254        );
1255        let terms = terms(
1256            SettlementType::Cash,
1257            SettlementEvent::Expiry,
1258            Some(dec!(100.001)),
1259        );
1260        let cash = settlement_obligation(&option, &terms).map(|o| o.cash);
1261        assert_eq!(cash, Ok(Decimal::ZERO));
1262        assert!(matches!(cash, Ok(value) if !value.is_sign_negative()));
1263    }
1264
1265    #[test]
1266    fn test_settlement_zero_quantity_has_no_flows() {
1267        let option = contract(
1268            OptionType::European,
1269            Side::Long,
1270            OptionStyle::Call,
1271            STRIKE,
1272            Decimal::ZERO,
1273            MULTIPLIER,
1274        );
1275        let terms = terms(
1276            SettlementType::Physical,
1277            SettlementEvent::Expiry,
1278            Some(IN_THE_MONEY_CALL),
1279        );
1280        let result = settlement_obligation(&option, &terms);
1281        assert!(matches!(
1282            result,
1283            Ok(SettlementObligation { extinguished_quantity, cash, underlying })
1284                if extinguished_quantity.is_zero() && cash.is_zero() && underlying.is_zero()
1285        ));
1286    }
1287
1288    #[test]
1289    fn test_settlement_american_settles_like_european() {
1290        for settlement_type in [SettlementType::Cash, SettlementType::Physical] {
1291            let terms = terms(
1292                settlement_type,
1293                SettlementEvent::Exercise,
1294                Some(IN_THE_MONEY_CALL),
1295            );
1296            let american = contract(
1297                OptionType::American,
1298                Side::Long,
1299                OptionStyle::Call,
1300                STRIKE,
1301                QUANTITY,
1302                MULTIPLIER,
1303            );
1304            assert_eq!(
1305                settlement_obligation(&american, &terms),
1306                settlement_obligation(&fixture(Side::Long, OptionStyle::Call), &terms)
1307            );
1308        }
1309    }
1310
1311    #[test]
1312    fn test_settlement_unsupported_option_type_is_rejected() {
1313        let terms = terms(
1314            SettlementType::Cash,
1315            SettlementEvent::Expiry,
1316            Some(IN_THE_MONEY_CALL),
1317        );
1318        for option_type in [
1319            OptionType::Bermuda {
1320                exercise_dates: vec![Positive::ONE],
1321            },
1322            OptionType::Asian {
1323                averaging_type: AsianAveragingType::Arithmetic,
1324            },
1325            OptionType::Barrier {
1326                barrier_type: BarrierType::UpAndIn,
1327                barrier_level: pos(dec!(120)),
1328                rebate: None,
1329            },
1330            OptionType::Cliquet {
1331                reset_dates: vec![Positive::ONE],
1332            },
1333        ] {
1334            let option = contract(
1335                option_type.clone(),
1336                Side::Long,
1337                OptionStyle::Call,
1338                STRIKE,
1339                QUANTITY,
1340                MULTIPLIER,
1341            );
1342            assert_eq!(
1343                settlement_obligation(&option, &terms),
1344                Err(SettlementError::UnsupportedOptionType { option_type })
1345            );
1346        }
1347    }
1348
1349    #[test]
1350    fn test_settlement_expiry_without_price_is_missing_price() {
1351        each_position(|side, style| {
1352            for settlement_type in [SettlementType::Cash, SettlementType::Physical] {
1353                let terms = terms(settlement_type, SettlementEvent::Expiry, None);
1354                assert_eq!(
1355                    settlement_obligation(&fixture(side, style), &terms),
1356                    Err(SettlementError::MissingPrice {
1357                        settlement_type,
1358                        event: SettlementEvent::Expiry,
1359                    })
1360                );
1361            }
1362        });
1363    }
1364
1365    #[test]
1366    fn test_settlement_explicit_cash_without_price_is_missing_price() {
1367        each_position(|side, style| {
1368            let event = explicit_event(side);
1369            let terms = terms(SettlementType::Cash, event, None);
1370            assert_eq!(
1371                settlement_obligation(&fixture(side, style), &terms),
1372                Err(SettlementError::MissingPrice {
1373                    settlement_type: SettlementType::Cash,
1374                    event,
1375                })
1376            );
1377        });
1378    }
1379
1380    #[test]
1381    fn test_settlement_price_after_cutoff_is_rejected_for_every_event() {
1382        let observed_at = cutoff() + TimeDelta::seconds(1);
1383        each_position(|side, style| {
1384            for event in [SettlementEvent::Expiry, explicit_event(side)] {
1385                for settlement_type in [SettlementType::Cash, SettlementType::Physical] {
1386                    let mut terms = terms(settlement_type, event, Some(STRIKE));
1387                    if let Some(price) = terms.price.as_mut() {
1388                        price.observed_at = observed_at;
1389                    }
1390                    assert_eq!(
1391                        settlement_obligation(&fixture(side, style), &terms),
1392                        Err(SettlementError::PriceAfterCutoff {
1393                            observed_at,
1394                            cutoff: cutoff(),
1395                        })
1396                    );
1397                }
1398            }
1399        });
1400    }
1401
1402    #[test]
1403    fn test_settlement_price_observed_at_or_before_cutoff_is_accepted() {
1404        for offset in [TimeDelta::zero(), TimeDelta::seconds(-1)] {
1405            let mut terms = terms(
1406                SettlementType::Cash,
1407                SettlementEvent::Expiry,
1408                Some(IN_THE_MONEY_CALL),
1409            );
1410            if let Some(price) = terms.price.as_mut() {
1411                price.observed_at = cutoff() + offset;
1412            }
1413            assert_eq!(
1414                settle(Side::Long, OptionStyle::Call, &terms),
1415                cash_flows(Side::Long)
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn test_settlement_exercise_of_short_is_event_side_mismatch() {
1422        for style in STYLES {
1423            let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1424            assert_eq!(
1425                settlement_obligation(&fixture(Side::Short, style), &terms),
1426                Err(SettlementError::EventSideMismatch {
1427                    event: SettlementEvent::Exercise,
1428                    side: Side::Short,
1429                })
1430            );
1431        }
1432    }
1433
1434    #[test]
1435    fn test_settlement_assignment_of_long_is_event_side_mismatch() {
1436        for style in STYLES {
1437            let terms = terms(SettlementType::Physical, SettlementEvent::Assignment, None);
1438            assert_eq!(
1439                settlement_obligation(&fixture(Side::Long, style), &terms),
1440                Err(SettlementError::EventSideMismatch {
1441                    event: SettlementEvent::Assignment,
1442                    side: Side::Long,
1443                })
1444            );
1445        }
1446    }
1447
1448    fn overflow_operation(
1449        result: Result<SettlementObligation, SettlementError>,
1450    ) -> Option<&'static str> {
1451        match result {
1452            Err(SettlementError::ArithmeticOverflow { operation, .. }) => Some(operation),
1453            _ => None,
1454        }
1455    }
1456
1457    #[test]
1458    fn test_settlement_position_size_overflow_is_an_error() {
1459        let option = contract(
1460            OptionType::European,
1461            Side::Long,
1462            OptionStyle::Call,
1463            STRIKE,
1464            Decimal::MAX,
1465            dec!(2),
1466        );
1467        let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1468        assert_eq!(
1469            overflow_operation(settlement_obligation(&option, &terms)),
1470            Some("position size")
1471        );
1472    }
1473
1474    #[test]
1475    fn test_settlement_physical_cash_overflow_is_an_error() {
1476        let option = contract(
1477            OptionType::European,
1478            Side::Long,
1479            OptionStyle::Call,
1480            dec!(10000000000000000000000000000),
1481            dec!(100),
1482            Decimal::ONE,
1483        );
1484        let terms = terms(SettlementType::Physical, SettlementEvent::Exercise, None);
1485        assert_eq!(
1486            overflow_operation(settlement_obligation(&option, &terms)),
1487            Some("physical cash")
1488        );
1489    }
1490
1491    #[test]
1492    fn test_settlement_cash_overflow_is_an_error() {
1493        let option = contract(
1494            OptionType::European,
1495            Side::Long,
1496            OptionStyle::Put,
1497            dec!(10000000000000000000000000000),
1498            dec!(100),
1499            Decimal::ONE,
1500        );
1501        let terms = terms(
1502            SettlementType::Cash,
1503            SettlementEvent::Expiry,
1504            Some(Decimal::ONE),
1505        );
1506        assert_eq!(
1507            overflow_operation(settlement_obligation(&option, &terms)),
1508            Some("cash settlement")
1509        );
1510    }
1511
1512    #[test]
1513    fn test_settlement_types_serialize_as_snake_case() {
1514        assert_eq!(
1515            serde_json::to_string(&SettlementType::Physical)
1516                .ok()
1517                .as_deref(),
1518            Some("\"physical\"")
1519        );
1520        assert_eq!(
1521            serde_json::to_string(&SettlementEvent::Assignment)
1522                .ok()
1523                .as_deref(),
1524            Some("\"assignment\"")
1525        );
1526    }
1527
1528    #[test]
1529    fn test_settlement_terms_round_trip_through_json() {
1530        let terms = terms(
1531            SettlementType::Cash,
1532            SettlementEvent::Expiry,
1533            Some(IN_THE_MONEY_CALL),
1534        );
1535        let json = serde_json::to_string(&terms);
1536        assert!(json.is_ok());
1537        let parsed = json.and_then(|text| serde_json::from_str::<SettlementTerms>(&text));
1538        assert_eq!(parsed.ok(), Some(terms));
1539    }
1540
1541    #[test]
1542    fn test_settlement_terms_reject_unknown_fields() {
1543        let terms = terms(
1544            SettlementType::Cash,
1545            SettlementEvent::Expiry,
1546            Some(IN_THE_MONEY_CALL),
1547        );
1548        assert_rejects_unknown_field(&CashRounding { scale: 2 });
1549        assert_rejects_unknown_field(&observed(IN_THE_MONEY_CALL));
1550        assert_rejects_unknown_field(&terms);
1551    }
1552
1553    /// Asserts that `value` parses back from JSON and stops parsing once its
1554    /// object gains a field the type does not declare.
1555    fn assert_rejects_unknown_field<T>(value: &T)
1556    where
1557        T: Serialize + DeserializeOwned,
1558    {
1559        let mut json = serde_json::to_value(value).unwrap_or_default();
1560        assert!(serde_json::from_value::<T>(json.clone()).is_ok());
1561        if let Some(fields) = json.as_object_mut() {
1562            fields.insert("unexpected".to_string(), serde_json::Value::Bool(true));
1563        }
1564        let error = serde_json::from_value::<T>(json)
1565            .err()
1566            .map(|e| e.to_string());
1567        assert!(
1568            error
1569                .as_deref()
1570                .is_some_and(|text| text.contains("unknown field")),
1571            "{error:?}"
1572        );
1573    }
1574}