Skip to main content

hypercall_settlement/
lib.rs

1//! Pure settlement arithmetic for Hypercall options.
2//!
3//! This crate computes option settlement payouts at expiry. It has **no IO,
4//! no state, and no async** -- just deterministic arithmetic over [`Decimal`]
5//! values. This makes it auditable, formally verifiable, and safe to call
6//! from any context.
7//!
8//! # Invariants (verified by Kani bounded model checking)
9//!
10//! - **Non-negative intrinsic value**: `intrinsic_value() >= 0` for all inputs.
11//! - **Call-put parity**: `call_iv - put_iv = spot - strike`.
12//! - **Zero-sum**: for any matched long/short pair with the same parameters,
13//!   `long.net_pnl + short.net_pnl = 0`. Settlement cannot create or destroy value.
14//! - **No panics**: `settle_position` does not panic on any valid `Decimal` input.
15//!
16//! # Usage
17//!
18//! ```
19//! use hypercall_settlement::{OptionType, SettlementInput, settle_position};
20//! use rust_decimal_macros::dec;
21//!
22//! let output = settle_position(&SettlementInput {
23//!     option_type: OptionType::Call,
24//!     strike: dec!(80000),
25//!     reference_price: dec!(85000),
26//!     position_size: dec!(1),
27//!     entry_price: dec!(3000),
28//! });
29//!
30//! assert_eq!(output.intrinsic_value, dec!(5000));
31//! assert_eq!(output.settlement_value, dec!(5000));
32//! assert_eq!(output.cost_basis, dec!(3000));
33//! assert_eq!(output.net_pnl, dec!(2000));
34//! ```
35//!
36//! # Settlement math
37//!
38//! For a position with size `s`, strike `K`, spot at expiry `S`, and entry price `e`:
39//!
40//! ```text
41//! intrinsic_value = max(S - K, 0)  for calls
42//!                   max(K - S, 0)  for puts
43//!
44//! settlement_value = intrinsic_value * s
45//! cost_basis       = s * e
46//! net_pnl          = settlement_value - cost_basis
47//! ```
48//!
49//! All intermediate results are quantized to 8 decimal places
50//! (MidpointAwayFromZero rounding).
51
52use rust_decimal::Decimal;
53use rust_decimal::RoundingStrategy;
54use rust_decimal_macros::dec;
55use serde::{Deserialize, Serialize};
56
57mod api;
58mod error;
59mod events;
60mod persistence;
61mod planning;
62mod symbol;
63
64pub use api::{normalize_payout_ids, SettlementPayoutView};
65pub use error::SettlementError;
66pub use events::{PositionExpiredMessage, SettlementEconomics};
67pub use persistence::{
68    settlement_cash_delta_for_margin_mode, validate_settlement_economics,
69    validate_settlement_value, SettlementPersistenceIntent,
70};
71pub use planning::{build_position_expired_message, plan_position_settlements, SettlementPosition};
72pub use symbol::{ParsedSettlementSymbol, SettlementInstrument};
73
74const SCALE_DP: u32 = 8;
75
76/// Option type: European-style call or put.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum OptionType {
79    Call,
80    Put,
81}
82
83impl From<hypercall_types::OptionType> for OptionType {
84    fn from(option_type: hypercall_types::OptionType) -> Self {
85        match option_type {
86            hypercall_types::OptionType::Call => Self::Call,
87            hypercall_types::OptionType::Put => Self::Put,
88        }
89    }
90}
91
92/// Inputs required to settle a single option position at expiry.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SettlementInput {
95    pub option_type: OptionType,
96    /// Strike price of the option contract.
97    pub strike: Decimal,
98    /// Spot price of the underlying at expiry (TWAP or oracle price).
99    pub reference_price: Decimal,
100    /// Signed position size. Positive = long, negative = short.
101    pub position_size: Decimal,
102    /// Per-contract entry price (cost basis per unit).
103    pub entry_price: Decimal,
104}
105
106/// Settlement result for a single position.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SettlementOutput {
109    /// Per-contract intrinsic value at expiry. Always >= 0.
110    pub intrinsic_value: Decimal,
111    /// Total settlement value: `intrinsic_value * position_size`.
112    pub settlement_value: Decimal,
113    /// Total cost basis: `position_size * entry_price`.
114    pub cost_basis: Decimal,
115    /// Net PnL: `settlement_value - cost_basis`.
116    pub net_pnl: Decimal,
117}
118
119fn quantize(value: Decimal) -> Decimal {
120    value.round_dp_with_strategy(SCALE_DP, RoundingStrategy::MidpointAwayFromZero)
121}
122
123/// Compute the intrinsic value of an option at expiry.
124///
125/// Returns `max(spot - strike, 0)` for calls, `max(strike - spot, 0)` for puts.
126/// The result is always non-negative.
127pub fn intrinsic_value(
128    option_type: OptionType,
129    strike: Decimal,
130    reference_price: Decimal,
131) -> Decimal {
132    match option_type {
133        OptionType::Call => (reference_price - strike).max(dec!(0)),
134        OptionType::Put => (strike - reference_price).max(dec!(0)),
135    }
136}
137
138/// Settle a single option position at expiry.
139///
140/// This is a pure function: same inputs always produce the same outputs.
141/// All monetary values in the output are quantized to 8 decimal places.
142pub fn settle_position(input: &SettlementInput) -> SettlementOutput {
143    let iv = intrinsic_value(input.option_type, input.strike, input.reference_price);
144    let settlement_value = iv * input.position_size;
145    let cost_basis = quantize(input.position_size * input.entry_price);
146    let net_pnl = quantize(settlement_value - cost_basis);
147
148    SettlementOutput {
149        intrinsic_value: iv,
150        settlement_value,
151        cost_basis,
152        net_pnl,
153    }
154}
155
156/// Settle a batch of positions and return per-position outputs plus total PnL.
157///
158/// If the input positions net to zero (sum of `position_size` = 0, same strike/expiry/entry),
159/// then `total_pnl` will be zero. This is the zero-sum invariant.
160pub fn settle_batch(inputs: &[SettlementInput]) -> (Vec<SettlementOutput>, Decimal) {
161    let mut outputs = Vec::with_capacity(inputs.len());
162    let mut total_pnl = Decimal::ZERO;
163    for input in inputs {
164        let out = settle_position(input);
165        total_pnl += out.net_pnl;
166        outputs.push(out);
167    }
168    (outputs, total_pnl)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use rust_decimal_macros::dec;
175
176    #[test]
177    fn call_itm() {
178        let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(130));
179        assert_eq!(iv, dec!(30));
180    }
181
182    #[test]
183    fn call_otm() {
184        let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(80));
185        assert_eq!(iv, dec!(0));
186    }
187
188    #[test]
189    fn call_atm() {
190        let iv = intrinsic_value(OptionType::Call, dec!(100), dec!(100));
191        assert_eq!(iv, dec!(0));
192    }
193
194    #[test]
195    fn put_itm() {
196        let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(70));
197        assert_eq!(iv, dec!(30));
198    }
199
200    #[test]
201    fn put_otm() {
202        let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(130));
203        assert_eq!(iv, dec!(0));
204    }
205
206    #[test]
207    fn put_atm() {
208        let iv = intrinsic_value(OptionType::Put, dec!(100), dec!(100));
209        assert_eq!(iv, dec!(0));
210    }
211
212    #[test]
213    fn long_call_settlement() {
214        let out = settle_position(&SettlementInput {
215            option_type: OptionType::Call,
216            strike: dec!(100),
217            reference_price: dec!(130),
218            position_size: dec!(2),
219            entry_price: dec!(100),
220        });
221        assert_eq!(out.intrinsic_value, dec!(30));
222        assert_eq!(out.settlement_value, dec!(60));
223        assert_eq!(out.cost_basis, dec!(200));
224        assert_eq!(out.net_pnl, dec!(-140));
225    }
226
227    #[test]
228    fn short_put_settlement() {
229        let out = settle_position(&SettlementInput {
230            option_type: OptionType::Put,
231            strike: dec!(100),
232            reference_price: dec!(70),
233            position_size: dec!(-3),
234            entry_price: dec!(120),
235        });
236        assert_eq!(out.intrinsic_value, dec!(30));
237        assert_eq!(out.settlement_value, dec!(-90));
238        assert_eq!(out.cost_basis, dec!(-360));
239        assert_eq!(out.net_pnl, dec!(270));
240    }
241
242    #[test]
243    fn parses_settlement_symbol_and_expiry_timestamp() {
244        let parsed = ParsedSettlementSymbol::from_symbol("BTC-20250115-100000-C")
245            .expect("symbol should parse");
246        assert_eq!(parsed.underlying, "BTC");
247        assert_eq!(parsed.expiry, 20250115);
248        assert_eq!(parsed.expiry_ts, 1736928000);
249        assert_eq!(parsed.strike, dec!(100000));
250        assert_eq!(parsed.option_type, OptionType::Call);
251    }
252
253    #[test]
254    fn accepts_one_or_two_digit_deribit_day() {
255        let one_digit = ParsedSettlementSymbol::from_symbol("BTC-1JAN25-100000-C")
256            .expect("one-digit day should parse");
257        let two_digit = ParsedSettlementSymbol::from_symbol("BTC-01JAN25-100000-C")
258            .expect("two-digit day should parse");
259
260        assert_eq!(one_digit.expiry, 20250101);
261        assert_eq!(two_digit.expiry, 20250101);
262    }
263
264    #[test]
265    fn rejects_malformed_deribit_expiry() {
266        assert!(ParsedSettlementSymbol::from_symbol("BTC-01JAN2025-100000-C").is_err());
267        assert!(ParsedSettlementSymbol::from_symbol("BTC-00JAN25-100000-C").is_err());
268        assert!(ParsedSettlementSymbol::from_symbol("BTC-001JAN25-100000-C").is_err());
269    }
270
271    #[test]
272    fn rejects_partial_settlement_economics() {
273        let err = validate_settlement_economics(Some(dec!(1)), None, Some(dec!(2)), "test")
274            .expect_err("partial economics should fail");
275        assert!(err.message().contains("Partial settlement economics tuple"));
276    }
277
278    #[test]
279    fn builds_persistence_intent_from_position_expired() {
280        let wallet = hypercall_types::WalletAddress::from([7u8; 20]);
281        let message = PositionExpiredMessage {
282            wallet_address: wallet,
283            margin_mode: hypercall_types::MarginMode::Standard,
284            symbol: "BTC-20250115-100000-C".to_string(),
285            position_size: dec!(2),
286            settlement_price: dec!(5000),
287            settlement_value: dec!(10000),
288            settlement_entry_price: Some(dec!(3000)),
289            cost_basis: Some(dec!(6000)),
290            net_pnl: Some(dec!(4000)),
291            timestamp: 1736928000000,
292        };
293
294        let intent = SettlementPersistenceIntent::from_position_expired(&message)
295            .expect("intent should build");
296        assert_eq!(intent.wallet, wallet);
297        assert_eq!(intent.margin_mode, hypercall_types::MarginMode::Standard);
298        assert_eq!(intent.expiry_ts, 1736928000);
299        assert_eq!(intent.settlement_value, dec!(10000));
300        assert_eq!(intent.economics.expect("economics").net_pnl, dec!(4000));
301    }
302
303    #[test]
304    fn rejects_inconsistent_persistence_intent_cashflow() {
305        let wallet = hypercall_types::WalletAddress::from([7u8; 20]);
306        let message = PositionExpiredMessage {
307            wallet_address: wallet,
308            margin_mode: hypercall_types::MarginMode::Standard,
309            symbol: "BTC-20250115-100000-C".to_string(),
310            position_size: dec!(2),
311            settlement_price: dec!(5000),
312            settlement_value: dec!(9999),
313            settlement_entry_price: None,
314            cost_basis: None,
315            net_pnl: None,
316            timestamp: 1736928000000,
317        };
318
319        let err = SettlementPersistenceIntent::from_position_expired(&message)
320            .expect_err("inconsistent settlement value should fail");
321        assert!(err.message().contains("Settlement value mismatch"));
322    }
323
324    #[test]
325    fn rejects_overflowing_persistence_intent_cashflow() {
326        let err = validate_settlement_value(Decimal::MAX, dec!(2), Decimal::ZERO, "overflow")
327            .expect_err("overflowing settlement value should fail");
328        assert!(err.message().contains("Settlement value overflow"));
329    }
330
331    #[test]
332    fn computes_margin_mode_cash_delta() {
333        assert_eq!(
334            settlement_cash_delta_for_margin_mode(
335                hypercall_types::MarginMode::Standard,
336                dec!(5000),
337                Some(dec!(4000)),
338                "standard",
339            )
340            .expect("standard delta"),
341            dec!(5000)
342        );
343        assert_eq!(
344            settlement_cash_delta_for_margin_mode(
345                hypercall_types::MarginMode::Portfolio,
346                dec!(5000),
347                Some(dec!(4000)),
348                "portfolio",
349            )
350            .expect("portfolio delta"),
351            dec!(4000)
352        );
353        assert!(settlement_cash_delta_for_margin_mode(
354            hypercall_types::MarginMode::Portfolio,
355            dec!(5000),
356            None,
357            "portfolio",
358        )
359        .is_err());
360    }
361
362    #[test]
363    fn normalizes_payout_ids() {
364        let ids = normalize_payout_ids(&[3, 1, 3, 2]).expect("ids should normalize");
365        assert_eq!(ids, vec![3, 1, 3, 2]);
366        assert!(normalize_payout_ids(&[]).is_err());
367        assert!(normalize_payout_ids(&[1, 0]).is_err());
368    }
369
370    #[test]
371    fn computes_settlement_economics() {
372        let out = settle_position(&SettlementInput {
373            option_type: OptionType::Call,
374            strike: dec!(0),
375            reference_price: dec!(130),
376            position_size: dec!(2),
377            entry_price: dec!(100),
378        });
379        assert_eq!(out.cost_basis, dec!(200));
380        assert_eq!(out.net_pnl, dec!(60));
381    }
382
383    #[test]
384    fn quantizes_to_eight_decimals() {
385        let out = settle_position(&SettlementInput {
386            option_type: OptionType::Call,
387            strike: dec!(0),
388            reference_price: dec!(1),
389            position_size: dec!(1.000000005),
390            entry_price: dec!(1.000000005),
391        });
392        assert_eq!(out.cost_basis.scale(), 8);
393        assert_eq!(out.net_pnl.scale(), 8);
394    }
395
396    #[test]
397    fn otm_call_zero_pnl() {
398        let out = settle_position(&SettlementInput {
399            option_type: OptionType::Call,
400            strike: dec!(100),
401            reference_price: dec!(80),
402            position_size: dec!(5),
403            entry_price: dec!(10),
404        });
405        assert_eq!(out.intrinsic_value, dec!(0));
406        assert_eq!(out.settlement_value, dec!(0));
407        assert_eq!(out.cost_basis, dec!(50));
408        assert_eq!(out.net_pnl, dec!(-50));
409    }
410
411    #[test]
412    fn zero_sum_long_short_pair() {
413        let strike = dec!(100);
414        let reference_price = dec!(130);
415        let entry_price = dec!(15);
416
417        let long = settle_position(&SettlementInput {
418            option_type: OptionType::Call,
419            strike,
420            reference_price,
421            position_size: dec!(1),
422            entry_price,
423        });
424        let short = settle_position(&SettlementInput {
425            option_type: OptionType::Call,
426            strike,
427            reference_price,
428            position_size: dec!(-1),
429            entry_price,
430        });
431
432        assert_eq!(long.net_pnl + short.net_pnl, dec!(0));
433        assert_eq!(long.settlement_value + short.settlement_value, dec!(0));
434        assert_eq!(long.cost_basis + short.cost_basis, dec!(0));
435    }
436
437    #[test]
438    fn zero_sum_batch() {
439        let inputs = vec![
440            SettlementInput {
441                option_type: OptionType::Call,
442                strike: dec!(100),
443                reference_price: dec!(130),
444                position_size: dec!(5),
445                entry_price: dec!(12),
446            },
447            SettlementInput {
448                option_type: OptionType::Call,
449                strike: dec!(100),
450                reference_price: dec!(130),
451                position_size: dec!(-3),
452                entry_price: dec!(12),
453            },
454            SettlementInput {
455                option_type: OptionType::Call,
456                strike: dec!(100),
457                reference_price: dec!(130),
458                position_size: dec!(-2),
459                entry_price: dec!(12),
460            },
461        ];
462
463        let (_, total_pnl) = settle_batch(&inputs);
464        assert_eq!(total_pnl, dec!(0));
465    }
466}
467
468#[cfg(kani)]
469mod verification {
470    use super::*;
471
472    // --- Full-range helpers (i64 mantissa, nightly) ---
473
474    fn any_decimal() -> Decimal {
475        let mantissa: i64 = kani::any();
476        let scale: u32 = kani::any();
477        kani::assume(scale <= 8);
478        kani::assume(mantissa.checked_abs().is_some());
479        Decimal::new(mantissa, scale)
480    }
481
482    fn any_positive_decimal() -> Decimal {
483        let mantissa: i64 = kani::any();
484        let scale: u32 = kani::any();
485        kani::assume(scale <= 8);
486        kani::assume(mantissa > 0);
487        Decimal::new(mantissa, scale)
488    }
489
490    // --- Bounded helpers (i16 mantissa, fast ~1min per harness) ---
491
492    fn any_decimal_fast() -> Decimal {
493        let mantissa: i16 = kani::any();
494        let scale: u32 = kani::any();
495        kani::assume(scale <= 4);
496        Decimal::new(mantissa as i64, scale)
497    }
498
499    fn any_positive_decimal_fast() -> Decimal {
500        let mantissa: i16 = kani::any();
501        let scale: u32 = kani::any();
502        kani::assume(scale <= 4);
503        kani::assume(mantissa > 0);
504        Decimal::new(mantissa as i64, scale)
505    }
506
507    fn any_option_type() -> OptionType {
508        if kani::any() {
509            OptionType::Call
510        } else {
511            OptionType::Put
512        }
513    }
514
515    // =====================================================================
516    // Fast PR smoke harnesses. Keep these concrete so per-PR Kani cannot
517    // burn the full job window on symbolic decimal arithmetic.
518    // =====================================================================
519
520    #[kani::proof]
521    fn fast_settlement_smoke() {
522        let out = settle_position(&SettlementInput {
523            option_type: OptionType::Call,
524            strike: Decimal::new(100, 0),
525            reference_price: Decimal::new(125, 0),
526            position_size: Decimal::new(2, 0),
527            entry_price: Decimal::new(10, 0),
528        });
529
530        assert_eq!(out.settlement_value, Decimal::new(50, 0));
531        assert_eq!(out.cost_basis, Decimal::new(20, 0));
532        assert_eq!(out.net_pnl, Decimal::new(30, 0));
533    }
534
535    // =====================================================================
536    // Bounded symbolic harnesses (i16 mantissa, scale <= 4).
537    // These are still Kani proofs, but they run in the scheduled full
538    // workflow because symbolic rust_decimal operations are not PR-fast.
539    // =====================================================================
540
541    #[kani::proof]
542    #[kani::unwind(2)]
543    fn bounded_intrinsic_non_negative() {
544        let ot = any_option_type();
545        let iv = intrinsic_value(ot, any_positive_decimal_fast(), any_positive_decimal_fast());
546        assert!(iv >= Decimal::ZERO);
547    }
548
549    #[kani::proof]
550    #[kani::unwind(2)]
551    fn bounded_call_put_parity() {
552        let strike = any_positive_decimal_fast();
553        let reference = any_positive_decimal_fast();
554        let call_iv = intrinsic_value(OptionType::Call, strike, reference);
555        let put_iv = intrinsic_value(OptionType::Put, strike, reference);
556        assert_eq!(call_iv - put_iv, reference - strike);
557    }
558
559    #[kani::proof]
560    #[kani::unwind(2)]
561    fn bounded_zero_sum_pnl() {
562        let ot = any_option_type();
563        let strike = any_positive_decimal_fast();
564        let reference = any_positive_decimal_fast();
565        let size = any_decimal_fast();
566        let entry = any_decimal_fast();
567
568        let long = settle_position(&SettlementInput {
569            option_type: ot,
570            strike,
571            reference_price: reference,
572            position_size: size,
573            entry_price: entry,
574        });
575        let short = settle_position(&SettlementInput {
576            option_type: ot,
577            strike,
578            reference_price: reference,
579            position_size: -size,
580            entry_price: entry,
581        });
582
583        assert_eq!(
584            long.settlement_value + short.settlement_value,
585            Decimal::ZERO
586        );
587        assert_eq!(long.cost_basis + short.cost_basis, Decimal::ZERO);
588        assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
589    }
590
591    #[kani::proof]
592    #[kani::unwind(2)]
593    fn bounded_no_panic_settle() {
594        let _out = settle_position(&SettlementInput {
595            option_type: any_option_type(),
596            strike: any_positive_decimal_fast(),
597            reference_price: any_positive_decimal_fast(),
598            position_size: any_decimal_fast(),
599            entry_price: any_decimal_fast(),
600        });
601    }
602
603    // =====================================================================
604    // Full harnesses (i64 mantissa, scale <= 8) -- nightly only
605    // =====================================================================
606
607    #[kani::proof]
608    #[kani::unwind(2)]
609    fn full_intrinsic_non_negative() {
610        let ot = any_option_type();
611        let iv = intrinsic_value(ot, any_positive_decimal(), any_positive_decimal());
612        assert!(iv >= Decimal::ZERO);
613    }
614
615    #[kani::proof]
616    #[kani::unwind(2)]
617    fn full_call_put_parity() {
618        let strike = any_positive_decimal();
619        let reference = any_positive_decimal();
620        let call_iv = intrinsic_value(OptionType::Call, strike, reference);
621        let put_iv = intrinsic_value(OptionType::Put, strike, reference);
622        assert_eq!(call_iv - put_iv, reference - strike);
623    }
624
625    #[kani::proof]
626    #[kani::unwind(2)]
627    fn full_zero_sum_pnl() {
628        let ot = any_option_type();
629        let strike = any_positive_decimal();
630        let reference = any_positive_decimal();
631        let size = any_decimal();
632        let entry = any_decimal();
633
634        let long = settle_position(&SettlementInput {
635            option_type: ot,
636            strike,
637            reference_price: reference,
638            position_size: size,
639            entry_price: entry,
640        });
641        let short = settle_position(&SettlementInput {
642            option_type: ot,
643            strike,
644            reference_price: reference,
645            position_size: -size,
646            entry_price: entry,
647        });
648
649        assert_eq!(
650            long.settlement_value + short.settlement_value,
651            Decimal::ZERO
652        );
653        assert_eq!(long.cost_basis + short.cost_basis, Decimal::ZERO);
654        assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
655    }
656
657    #[kani::proof]
658    #[kani::unwind(2)]
659    fn full_zero_position_zero_pnl() {
660        let ot = any_option_type();
661        let entry = any_decimal();
662        let out = settle_position(&SettlementInput {
663            option_type: ot,
664            strike: any_positive_decimal(),
665            reference_price: any_positive_decimal(),
666            position_size: Decimal::ZERO,
667            entry_price: entry,
668        });
669        assert_eq!(out.settlement_value, Decimal::ZERO);
670        assert_eq!(out.cost_basis, Decimal::ZERO);
671        assert_eq!(out.net_pnl, Decimal::ZERO);
672    }
673
674    #[kani::proof]
675    #[kani::unwind(2)]
676    fn full_otm_zero_intrinsic() {
677        let strike = any_positive_decimal();
678        let reference = any_positive_decimal();
679        kani::assume(reference <= strike);
680        assert_eq!(
681            intrinsic_value(OptionType::Call, strike, reference),
682            Decimal::ZERO
683        );
684    }
685
686    #[kani::proof]
687    #[kani::unwind(2)]
688    fn full_itm_equals_spread() {
689        let strike = any_positive_decimal();
690        let reference = any_positive_decimal();
691        kani::assume(strike <= reference);
692        assert_eq!(
693            intrinsic_value(OptionType::Call, strike, reference),
694            reference - strike
695        );
696    }
697
698    #[kani::proof]
699    #[kani::unwind(2)]
700    fn full_no_panic_settle() {
701        let _out = settle_position(&SettlementInput {
702            option_type: any_option_type(),
703            strike: any_positive_decimal(),
704            reference_price: any_positive_decimal(),
705            position_size: any_decimal(),
706            entry_price: any_decimal(),
707        });
708    }
709}
710
711#[cfg(test)]
712mod proptests {
713    use super::*;
714    use proptest::prelude::*;
715
716    fn decimal_strategy(lo: i64, hi: i64) -> impl Strategy<Value = Decimal> {
717        (lo..=hi).prop_map(|v| Decimal::new(v, 2))
718    }
719
720    proptest! {
721        #[test]
722        fn call_intrinsic_non_negative(
723            strike in decimal_strategy(1, 100000),
724            reference in decimal_strategy(1, 100000),
725        ) {
726            let iv = intrinsic_value(OptionType::Call, strike, reference);
727            prop_assert!(iv >= Decimal::ZERO);
728        }
729
730        #[test]
731        fn put_intrinsic_non_negative(
732            strike in decimal_strategy(1, 100000),
733            reference in decimal_strategy(1, 100000),
734        ) {
735            let iv = intrinsic_value(OptionType::Put, strike, reference);
736            prop_assert!(iv >= Decimal::ZERO);
737        }
738
739        #[test]
740        fn call_put_parity(
741            strike in decimal_strategy(1, 100000),
742            reference in decimal_strategy(1, 100000),
743        ) {
744            let call_iv = intrinsic_value(OptionType::Call, strike, reference);
745            let put_iv = intrinsic_value(OptionType::Put, strike, reference);
746            prop_assert_eq!(call_iv - put_iv, reference - strike);
747        }
748
749        #[test]
750        fn zero_sum_matched_pair(
751            strike in decimal_strategy(1, 100000),
752            reference in decimal_strategy(1, 100000),
753            size in decimal_strategy(1, 10000),
754            entry in decimal_strategy(1, 10000),
755        ) {
756            let long = settle_position(&SettlementInput {
757                option_type: OptionType::Call,
758                strike,
759                reference_price: reference,
760                position_size: size,
761                entry_price: entry,
762            });
763            let short = settle_position(&SettlementInput {
764                option_type: OptionType::Call,
765                strike,
766                reference_price: reference,
767                position_size: -size,
768                entry_price: entry,
769            });
770            prop_assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
771            prop_assert_eq!(long.settlement_value + short.settlement_value, Decimal::ZERO);
772        }
773
774        #[test]
775        fn zero_sum_matched_put_pair(
776            strike in decimal_strategy(1, 100000),
777            reference in decimal_strategy(1, 100000),
778            size in decimal_strategy(1, 10000),
779            entry in decimal_strategy(1, 10000),
780        ) {
781            let long = settle_position(&SettlementInput {
782                option_type: OptionType::Put,
783                strike,
784                reference_price: reference,
785                position_size: size,
786                entry_price: entry,
787            });
788            let short = settle_position(&SettlementInput {
789                option_type: OptionType::Put,
790                strike,
791                reference_price: reference,
792                position_size: -size,
793                entry_price: entry,
794            });
795            prop_assert_eq!(long.net_pnl + short.net_pnl, Decimal::ZERO);
796        }
797    }
798}