Skip to main content

hypercall_margin/
lib.rs

1//! Pure margin computation for the Hypercall options + perps platform.
2//!
3//! This crate contains all margin math with **zero IO dependencies** -- no database,
4//! no network, no async runtime. It is the correctness kernel for margin calculations.
5//!
6//! # Margin Modes
7//!
8//! Hypercall supports two margin modes:
9//!
10//! - **Standard** (Deribit-style): linear per-position margin. Long options are fully paid
11//!   (zero margin), short options use `max(15% * spot - OTM, 10% * spot)` for IM.
12//!   See [`standard::StandardMarginService`].
13//!
14//! - **Portfolio** (SPAN-style): scenario-based margin. The portfolio is stressed across
15//!   17 scenarios (13 core + 4 tail) combining spot and vol shocks. Scanning risk is the
16//!   worst-case weighted loss, plus contingency add-ons for short gamma near expiry.
17//!   See [`portfolio::span::compute_span_margin_at`].
18//!
19//! # Architecture
20//!
21//! ```text
22//! hypercall-types  (WalletAddress, shared primitives)
23//!       ^
24//! hypercall-margin (this crate: types + pure math)
25//!       ^
26//! hypercall        (engine: IO, vol oracle, async wiring)
27//! ```
28//!
29//! The engine populates market state (spot prices, implied vols) and calls into this
30//! crate for the actual margin computation. This crate never fetches data itself.
31//!
32//! # Example
33//!
34//! ```
35//! use hypercall_margin::standard::{StandardMarginService, StandardAccount};
36//! use rust_decimal_macros::dec;
37//!
38//! let service = StandardMarginService::new();
39//! let account = StandardAccount::new("wallet".to_string(), dec!(10000));
40//! let result = service.compute_margin(&account);
41//! assert!(result.can_increase_risk());
42//! ```
43
44pub mod black_76;
45pub mod black_scholes;
46pub mod constants;
47pub mod error;
48pub mod margin_mode {
49    pub use hypercall_types::margin_mode::*;
50}
51pub mod portfolio;
52pub mod standard;
53pub mod types;
54
55pub use constants::MM_TO_IM_RATIO;
56pub use error::MarginError;
57pub use margin_mode::MarginMode;
58pub use types::{Account, MarginDetails, OptionType, Scenario, ScenarioType};
59
60pub use portfolio::{
61    account_cash_decimal, classify_liquidity_gap, compute_extended_risk_grid_from_snapshot,
62    compute_risk_grid_from_snapshot, compute_span_margin_at, empty_portfolio_margin_details,
63    generate_scenarios, has_portfolio_positions, market_state_from_snapshot, pool_capacity_usdc,
64    pool_target_usdc, pool_utilization, short_option_oi_usdc, snapshot_from_account,
65    utilization_apr, validate_pool_config, ContingencyMargin, ExtendedRiskGrid, InstrumentRiskRow,
66    PmAccountSettlementFacts, PmFixtureOptionKey, PmFixturePosition, PmLiquidityClassification,
67    PmMarketMarks, PmSettlementFixture, PmSettlementModelError, PmSettlementObligation,
68    PmSettlementPoolConfig, PmSettlementPoolSnapshot, PortfolioMarginConfig,
69    PortfolioMarginContingencyConfig, PortfolioMarginGridConfig, PortfolioMarginMarketState,
70    PortfolioMarginOptionExposure, PortfolioMarginOptionKey, PortfolioMarginOptionMarketState,
71    PortfolioMarginPerpExposure, PortfolioMarginScenario, PortfolioMarginSnapshot,
72    PortfolioMarginSymbolOverride, PortfolioMarginUnderlyingMarketState,
73    PortfolioMarginUnderlyingSnapshot, ScenarioPnl, SnapshotComponentKind,
74};
75
76pub use standard::{
77    OptionPosition, PerpPosition, PositionMarginContribution, StandardAccount,
78    StandardMarginParams, StandardMarginResult, StandardMarginService,
79};
80
81#[cfg(test)]
82mod proptest_properties {
83    use super::standard::types::{OptionPosition, PerpPosition, StandardAccount};
84    use super::standard::StandardMarginService;
85    use proptest::prelude::*;
86    use rust_decimal::Decimal;
87    use rust_decimal_macros::dec;
88
89    fn arb_decimal_positive(max: f64) -> impl Strategy<Value = Decimal> {
90        (1u64..=(max as u64 * 100)).prop_map(|v| Decimal::new(v as i64, 2))
91    }
92
93    fn arb_decimal_nonzero(max: f64) -> impl Strategy<Value = Decimal> {
94        prop_oneof![
95            (1u64..=(max as u64 * 100)).prop_map(|v| Decimal::new(v as i64, 2)),
96            (1u64..=(max as u64 * 100)).prop_map(|v| Decimal::new(-(v as i64), 2)),
97        ]
98    }
99
100    // ===== Property 1: Standard margin non-negativity =====
101
102    proptest! {
103        #[test]
104        fn standard_margin_is_non_negative(
105            spot in arb_decimal_positive(100000.0),
106            size in arb_decimal_nonzero(100.0),
107            strike in arb_decimal_positive(200000.0),
108            balance in arb_decimal_positive(1000000.0),
109        ) {
110            let service = StandardMarginService::new();
111            let mut account = StandardAccount::new("prop".to_string(), balance);
112            account.option_positions.push(OptionPosition {
113                symbol: "TEST-C".to_string(),
114                underlying: "TEST".to_string(),
115                expiry_ts: 0,
116                strike,
117                is_call: true,
118                size,
119                mark_price: spot / dec!(10),
120                entry_price: spot / dec!(10),
121                spot_price: spot,
122            });
123
124            let result = service.compute_margin(&account);
125            prop_assert!(result.position_im >= dec!(0), "position_im was {}", result.position_im);
126            prop_assert!(result.position_mm >= dec!(0), "position_mm was {}", result.position_mm);
127        }
128
129    // ===== Property 2: Adding position never decreases margin =====
130
131        #[test]
132        fn adding_position_never_decreases_margin(
133            spot in arb_decimal_positive(100000.0),
134            size1 in arb_decimal_nonzero(50.0),
135            size2 in arb_decimal_nonzero(50.0),
136            strike1 in arb_decimal_positive(200000.0),
137            strike2 in arb_decimal_positive(200000.0),
138        ) {
139            let service = StandardMarginService::new();
140
141            let mut account1 = StandardAccount::new("prop".to_string(), dec!(1000000));
142            account1.option_positions.push(OptionPosition {
143                symbol: "A-C".to_string(),
144                underlying: "A".to_string(),
145                expiry_ts: 0,
146                strike: strike1,
147                is_call: true,
148                size: size1,
149                mark_price: spot / dec!(10),
150                entry_price: spot / dec!(10),
151                spot_price: spot,
152            });
153
154            let mut account2 = account1.clone();
155            account2.option_positions.push(OptionPosition {
156                symbol: "B-C".to_string(),
157                underlying: "B".to_string(),
158                expiry_ts: 0,
159                strike: strike2,
160                is_call: true,
161                size: size2,
162                mark_price: spot / dec!(10),
163                entry_price: spot / dec!(10),
164                spot_price: spot,
165            });
166
167            let result1 = service.compute_margin(&account1);
168            let result2 = service.compute_margin(&account2);
169
170            prop_assert!(
171                result2.position_im >= result1.position_im,
172                "Adding position decreased IM: {} -> {}",
173                result1.position_im,
174                result2.position_im
175            );
176        }
177
178    // ===== Property 3: Perp margin scales linearly =====
179
180        #[test]
181        fn perp_margin_scales_linearly_with_size(
182            spot in arb_decimal_positive(100000.0),
183            size in arb_decimal_nonzero(100.0),
184        ) {
185            let service = StandardMarginService::new();
186
187            let mut account1 = StandardAccount::new("prop".to_string(), dec!(1000000));
188            account1.perp_positions.push(PerpPosition {
189                symbol: "TEST-PERP".to_string(),
190                underlying: "TEST".to_string(),
191                size,
192                mark_price: spot,
193                entry_price: spot,
194            });
195
196            let mut account2 = StandardAccount::new("prop".to_string(), dec!(1000000));
197            account2.perp_positions.push(PerpPosition {
198                symbol: "TEST-PERP".to_string(),
199                underlying: "TEST".to_string(),
200                size: size * dec!(2),
201                mark_price: spot,
202                entry_price: spot,
203            });
204
205            let result1 = service.compute_margin(&account1);
206            let result2 = service.compute_margin(&account2);
207
208            prop_assert_eq!(result2.position_im, result1.position_im * dec!(2));
209        }
210    }
211}
212
213// ===== Properties 4-7: SPAN / PM / Black-Scholes =====
214
215#[cfg(test)]
216mod span_proptest_properties {
217    use super::portfolio::config::*;
218    use super::portfolio::contingency::calculate_contingency_margin_at;
219    use super::portfolio::evaluator::calculate_scanning_risk;
220    use super::portfolio::snapshot::*;
221    use super::types::OptionType;
222    use hypercall_types::wallet_address::test_wallet;
223    use proptest::prelude::*;
224    use rust_decimal::Decimal;
225    use rust_decimal_macros::dec;
226    use std::collections::HashMap;
227
228    const FIXED_NOW_TS: i64 = 1_700_000_000;
229    const FAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 90 * 24 * 3600;
230
231    fn test_pm_config() -> PortfolioMarginConfig {
232        PortfolioMarginConfig {
233            base_grid: PortfolioMarginGridConfig {
234                scenarios: PortfolioMarginScenario::finalized_default_grid(),
235                base_volatility: 0.8,
236                base_skew: 0.0,
237                base_excess_kurtosis: 0.0,
238                delta_threshold: 0.0001,
239                strike_match_tolerance: 0.01,
240                expiry_match_tolerance_years: 0.001,
241            },
242            symbol_overrides: Vec::new(),
243            contingency: PortfolioMarginContingencyConfig::finalized_default(),
244            risk_free_rate: 0.05,
245        }
246    }
247
248    fn make_option_snapshot(
249        spot: f64,
250        strike: f64,
251        quantity: f64,
252        iv: f64,
253    ) -> (PortfolioMarginSnapshot, PortfolioMarginMarketState) {
254        let spot_dec = Decimal::from_f64_retain(spot).unwrap();
255        let strike_dec = Decimal::from_f64_retain(strike).unwrap();
256        let quantity_dec = Decimal::from_f64_retain(quantity).unwrap();
257
258        let key = PortfolioMarginOptionKey {
259            underlying: "BTC".to_string(),
260            option_type: OptionType::Call,
261            strike: strike_dec,
262            expiry_ts: FAR_EXPIRY_TS,
263        };
264
265        let snapshot = PortfolioMarginSnapshot {
266            wallet: test_wallet(200),
267            cash_balance: dec!(100000),
268            underlyings: vec![PortfolioMarginUnderlyingSnapshot {
269                underlying: "BTC".to_string(),
270                spot_price: spot_dec,
271                executed_options: vec![PortfolioMarginOptionExposure {
272                    key: key.clone(),
273                    expiry_years: dec!(0.25),
274                    quantity: quantity_dec,
275                    entry_price: dec!(0),
276                    source: SnapshotComponentKind::ExecutedPositions,
277                }],
278                hypothetical_open_order_options: Vec::new(),
279                executed_perps: Vec::new(),
280                hypothetical_open_order_perps: Vec::new(),
281            }],
282        };
283
284        let market_state = PortfolioMarginMarketState {
285            config: test_pm_config(),
286            underlyings: HashMap::from([(
287                "BTC".to_string(),
288                PortfolioMarginUnderlyingMarketState {
289                    spot_price: spot,
290                    option_inputs: HashMap::from([(
291                        key,
292                        PortfolioMarginOptionMarketState {
293                            implied_volatility: iv,
294                        },
295                    )]),
296                    funding: None,
297                },
298            )]),
299        };
300
301        (snapshot, market_state)
302    }
303
304    fn make_perp_snapshot(
305        spot: f64,
306        quantity: f64,
307    ) -> (PortfolioMarginSnapshot, PortfolioMarginMarketState) {
308        let spot_dec = Decimal::from_f64_retain(spot).unwrap();
309        let quantity_dec = Decimal::from_f64_retain(quantity).unwrap();
310
311        let snapshot = PortfolioMarginSnapshot {
312            wallet: test_wallet(201),
313            cash_balance: dec!(100000),
314            underlyings: vec![PortfolioMarginUnderlyingSnapshot {
315                underlying: "BTC".to_string(),
316                spot_price: spot_dec,
317                executed_options: Vec::new(),
318                hypothetical_open_order_options: Vec::new(),
319                executed_perps: vec![PortfolioMarginPerpExposure {
320                    underlying: "BTC".to_string(),
321                    quantity: quantity_dec,
322                    entry_price: Some(spot_dec),
323                    unrealized_pnl: dec!(0),
324                }],
325                hypothetical_open_order_perps: Vec::new(),
326            }],
327        };
328
329        let market_state = PortfolioMarginMarketState {
330            config: test_pm_config(),
331            underlyings: HashMap::from([(
332                "BTC".to_string(),
333                PortfolioMarginUnderlyingMarketState {
334                    spot_price: spot,
335                    option_inputs: HashMap::new(),
336                    funding: None,
337                },
338            )]),
339        };
340
341        (snapshot, market_state)
342    }
343
344    proptest! {
345        #![proptest_config(ProptestConfig::with_cases(256))]
346
347    // ===== Property 4: Scanning risk non-negativity =====
348
349        #[test]
350        fn scanning_risk_is_non_negative_for_options(
351            spot in 10.0f64..200000.0,
352            strike_ratio in 0.5f64..2.0,
353            quantity in -10.0f64..10.0,
354            iv in 0.1f64..3.0,
355        ) {
356            prop_assume!(quantity.abs() > 0.001);
357            let strike = spot * strike_ratio;
358            let (snapshot, market_state) = make_option_snapshot(spot, strike, quantity, iv);
359            let scenarios = super::generate_scenarios(&market_state);
360
361            let scanning_risk = calculate_scanning_risk(&snapshot, &market_state, &scenarios)
362                .expect("scanning risk should compute");
363
364            prop_assert!(
365                scanning_risk >= 0.0,
366                "scanning_risk was {} for spot={} strike={} qty={} iv={}",
367                scanning_risk, spot, strike, quantity, iv
368            );
369        }
370
371        #[test]
372        fn scanning_risk_is_non_negative_for_perps(
373            spot in 10.0f64..200000.0,
374            quantity in -100.0f64..100.0,
375        ) {
376            prop_assume!(quantity.abs() > 0.001);
377            let (snapshot, market_state) = make_perp_snapshot(spot, quantity);
378            let scenarios = super::generate_scenarios(&market_state);
379
380            let scanning_risk = calculate_scanning_risk(&snapshot, &market_state, &scenarios)
381                .expect("scanning risk should compute");
382
383            prop_assert!(
384                scanning_risk >= 0.0,
385                "scanning_risk was {} for spot={} qty={}",
386                scanning_risk, spot, quantity
387            );
388        }
389
390    // ===== Property 5: Hedging reduces scanning risk =====
391
392        #[test]
393        fn hedge_reduces_or_maintains_scanning_risk(
394            spot in 100.0f64..100000.0,
395            short_qty in 1.0f64..10.0,
396            hedge_ratio in 0.1f64..1.0,
397            iv in 0.3f64..2.0,
398        ) {
399            let strike = spot * 1.05;
400            let (naked_snapshot, market_state) = make_option_snapshot(spot, strike, -short_qty, iv);
401            let scenarios = super::generate_scenarios(&market_state);
402
403            let naked_risk = calculate_scanning_risk(&naked_snapshot, &market_state, &scenarios)
404                .expect("naked risk should compute");
405
406            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
407            let strike_dec = Decimal::from_f64_retain(strike).unwrap();
408            let short_qty_dec = Decimal::from_f64_retain(-short_qty).unwrap();
409            let hedge_qty_dec = Decimal::from_f64_retain(short_qty * hedge_ratio).unwrap();
410
411            let short_key = PortfolioMarginOptionKey {
412                underlying: "BTC".to_string(),
413                option_type: OptionType::Call,
414                strike: strike_dec,
415                expiry_ts: FAR_EXPIRY_TS,
416            };
417            let hedge_strike = strike * 1.1;
418            let hedge_strike_dec = Decimal::from_f64_retain(hedge_strike).unwrap();
419            let hedge_key = PortfolioMarginOptionKey {
420                underlying: "BTC".to_string(),
421                option_type: OptionType::Call,
422                strike: hedge_strike_dec,
423                expiry_ts: FAR_EXPIRY_TS,
424            };
425
426            let hedged_snapshot = PortfolioMarginSnapshot {
427                wallet: test_wallet(202),
428                cash_balance: dec!(100000),
429                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
430                    underlying: "BTC".to_string(),
431                    spot_price: spot_dec,
432                    executed_options: vec![
433                        PortfolioMarginOptionExposure {
434                            key: short_key.clone(),
435                            expiry_years: dec!(0.25),
436                            quantity: short_qty_dec,
437                            entry_price: dec!(0),
438                            source: SnapshotComponentKind::ExecutedPositions,
439                        },
440                        PortfolioMarginOptionExposure {
441                            key: hedge_key.clone(),
442                            expiry_years: dec!(0.25),
443                            quantity: hedge_qty_dec,
444                            entry_price: dec!(0),
445                            source: SnapshotComponentKind::ExecutedPositions,
446                        },
447                    ],
448                    hypothetical_open_order_options: Vec::new(),
449                    executed_perps: Vec::new(),
450                    hypothetical_open_order_perps: Vec::new(),
451                }],
452            };
453
454            let hedged_market_state = PortfolioMarginMarketState {
455                config: test_pm_config(),
456                underlyings: HashMap::from([(
457                    "BTC".to_string(),
458                    PortfolioMarginUnderlyingMarketState {
459                        spot_price: spot,
460                        option_inputs: HashMap::from([
461                            (short_key, PortfolioMarginOptionMarketState { implied_volatility: iv }),
462                            (hedge_key, PortfolioMarginOptionMarketState { implied_volatility: iv }),
463                        ]),
464                        funding: None,
465                    },
466                )]),
467            };
468
469            let hedged_risk = calculate_scanning_risk(&hedged_snapshot, &hedged_market_state, &scenarios)
470                .expect("hedged risk should compute");
471
472            prop_assert!(
473                hedged_risk <= naked_risk + 1e-6,
474                "hedge increased risk: naked={} hedged={} (spot={} qty={} hedge_ratio={})",
475                naked_risk, hedged_risk, spot, short_qty, hedge_ratio
476            );
477        }
478
479    // ===== Property 6: Contingency non-negativity =====
480
481        #[test]
482        fn contingency_margins_are_non_negative(
483            spot in 10.0f64..200000.0,
484            quantity in -50.0f64..50.0,
485            near_expiry in prop::bool::ANY,
486        ) {
487            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
488            let quantity_dec = Decimal::from_f64_retain(quantity).unwrap();
489            let expiry_ts = if near_expiry {
490                FIXED_NOW_TS + 12 * 3600
491            } else {
492                FAR_EXPIRY_TS
493            };
494
495            let snapshot = PortfolioMarginSnapshot {
496                wallet: test_wallet(203),
497                cash_balance: dec!(100000),
498                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
499                    underlying: "BTC".to_string(),
500                    spot_price: spot_dec,
501                    executed_options: vec![PortfolioMarginOptionExposure {
502                        key: PortfolioMarginOptionKey {
503                            underlying: "BTC".to_string(),
504                            option_type: OptionType::Call,
505                            strike: spot_dec,
506                            expiry_ts,
507                        },
508                        expiry_years: dec!(0.01),
509                        quantity: quantity_dec,
510                        entry_price: dec!(0),
511                        source: SnapshotComponentKind::ExecutedPositions,
512                    }],
513                    hypothetical_open_order_options: Vec::new(),
514                    executed_perps: Vec::new(),
515                    hypothetical_open_order_perps: Vec::new(),
516                }],
517            };
518
519            let config = test_pm_config();
520            let contingency = calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS)
521                .expect("contingency should compute");
522
523            prop_assert!(
524                contingency.option_floor >= 0.0,
525                "option_floor was {} for spot={} qty={}",
526                contingency.option_floor, spot, quantity
527            );
528            prop_assert!(
529                contingency.gamma_overlay >= 0.0,
530                "gamma_overlay was {} for spot={} qty={}",
531                contingency.gamma_overlay, spot, quantity
532            );
533        }
534
535    // ===== Property 7: IM >= MM always =====
536
537        #[test]
538        fn im_always_gte_mm(
539            spot in 100.0f64..100000.0,
540            quantity in -10.0f64..10.0,
541            iv in 0.1f64..3.0,
542        ) {
543            prop_assume!(quantity.abs() > 0.001);
544            let strike = spot * 1.1;
545            let (snapshot, market_state) = make_option_snapshot(spot, strike, quantity, iv);
546
547            let result = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS)
548                .expect("span margin should compute");
549
550            prop_assert!(
551                result.initial_margin_required >= result.maintenance_margin_required,
552                "IM ({}) < MM ({}) for spot={} qty={} iv={}",
553                result.initial_margin_required, result.maintenance_margin_required,
554                spot, quantity, iv
555            );
556        }
557
558    // ===== Property 8: PM floor property =====
559
560        #[test]
561        fn pm_never_drops_below_contingency_floor(
562            spot in 100.0f64..100000.0,
563            quantity in -10.0f64..-0.01,
564            iv in 0.1f64..3.0,
565        ) {
566            let strike = spot;
567            let (snapshot, market_state) = make_option_snapshot(spot, strike, quantity, iv);
568
569            let result = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS)
570                .expect("span margin should compute");
571
572            let im_f64 = result.initial_margin_required.to_string().parse::<f64>().unwrap();
573            let floor_f64 = result.option_floor.to_string().parse::<f64>().unwrap();
574
575            prop_assert!(
576                im_f64 >= floor_f64 - 1e-6,
577                "IM ({}) dropped below option floor ({}) for spot={} qty={} iv={}",
578                im_f64, floor_f64, spot, quantity, iv
579            );
580        }
581    }
582}
583
584// ===== Property 9-10: Black-Scholes bounds =====
585
586#[cfg(test)]
587mod bs_proptest_properties {
588    use super::black_scholes::{black_scholes, black_scholes_with_moments};
589    use super::types::OptionType;
590    use proptest::prelude::*;
591
592    proptest! {
593        #![proptest_config(ProptestConfig::with_cases(1024))]
594
595    // ===== Property 9: Call price in [0, spot] =====
596
597        #[test]
598        fn call_price_bounded_by_spot(
599            spot in 1.0f64..200000.0,
600            strike in 1.0f64..400000.0,
601            time in 0.001f64..5.0,
602            vol in 0.01f64..5.0,
603            rate in 0.0f64..0.2,
604        ) {
605            let price = black_scholes(&OptionType::Call, spot, strike, time, rate, vol);
606
607            prop_assert!(
608                price >= -1e-10,
609                "call price {} < 0 for spot={} strike={} t={} vol={} r={}",
610                price, spot, strike, time, vol, rate
611            );
612            prop_assert!(
613                price <= spot + 1e-10,
614                "call price {} > spot {} for strike={} t={} vol={} r={}",
615                price, spot, strike, time, vol, rate
616            );
617        }
618
619    // ===== Property 10: Put price in [0, strike * exp(-rT)] =====
620
621        #[test]
622        fn put_price_bounded_by_discounted_strike(
623            spot in 1.0f64..200000.0,
624            strike in 1.0f64..400000.0,
625            time in 0.001f64..5.0,
626            vol in 0.01f64..5.0,
627            rate in 0.0f64..0.2,
628        ) {
629            let price = black_scholes(&OptionType::Put, spot, strike, time, rate, vol);
630            let upper = strike * (-rate * time).exp();
631
632            prop_assert!(
633                price >= -1e-10,
634                "put price {} < 0 for spot={} strike={} t={} vol={} r={}",
635                price, spot, strike, time, vol, rate
636            );
637            prop_assert!(
638                price <= upper + 1e-10,
639                "put price {} > discounted strike {} for spot={} strike={} t={} vol={} r={}",
640                price, upper, spot, strike, time, vol, rate
641            );
642        }
643
644    // ===== Edgeworth-adjusted BS respects no-arbitrage bounds =====
645    //
646    // The Edgeworth expansion can produce raw prices outside [0, spot] or
647    // [0, K*exp(-rT)] for large moment adjustments. We clamp in the pricing
648    // function to enforce no-arbitrage bounds. This test covers the full
649    // parameter space to verify the clamp works.
650
651        #[test]
652        fn moment_adjusted_call_bounded(
653            spot in 1.0f64..100000.0,
654            strike in 1.0f64..200000.0,
655            time in 0.001f64..2.0,
656            vol in 0.1f64..3.0,
657            skew in -1.0f64..1.0,
658            kurtosis in -1.0f64..3.0,
659        ) {
660            let price = black_scholes_with_moments(
661                &OptionType::Call, spot, strike, time, 0.05, vol, skew, kurtosis,
662            );
663
664            prop_assert!(
665                price >= 0.0,
666                "moment-adjusted call {} < 0 for spot={} strike={} skew={} kurt={}",
667                price, spot, strike, skew, kurtosis
668            );
669            prop_assert!(
670                price <= spot,
671                "moment-adjusted call {} > spot {} for strike={} skew={} kurt={}",
672                price, spot, strike, skew, kurtosis
673            );
674        }
675
676        #[test]
677        fn moment_adjusted_put_bounded(
678            spot in 1.0f64..100000.0,
679            strike in 1.0f64..200000.0,
680            time in 0.001f64..2.0,
681            vol in 0.1f64..3.0,
682            skew in -1.0f64..1.0,
683            kurtosis in -1.0f64..3.0,
684        ) {
685            let price = black_scholes_with_moments(
686                &OptionType::Put, spot, strike, time, 0.05, vol, skew, kurtosis,
687            );
688            let upper = strike * (-0.05f64 * time).exp();
689
690            prop_assert!(
691                price >= 0.0,
692                "moment-adjusted put {} < 0 for spot={} strike={} skew={} kurt={}",
693                price, spot, strike, skew, kurtosis
694            );
695            prop_assert!(
696                price <= upper,
697                "moment-adjusted put {} > discounted strike {} for spot={} strike={} skew={} kurt={}",
698                price, upper, spot, strike, skew, kurtosis
699            );
700        }
701    }
702}
703
704// ===== Property 11: Equity additivity =====
705
706#[cfg(test)]
707mod equity_proptest_properties {
708    use super::portfolio::config::*;
709    use super::portfolio::equity::{calculate_net_option_upnl, calculate_net_perp_upnl};
710    use super::portfolio::snapshot::*;
711    use super::types::OptionType;
712    use hypercall_types::wallet_address::test_wallet;
713    use proptest::prelude::*;
714    use rust_decimal::Decimal;
715    use rust_decimal_macros::dec;
716    use std::collections::HashMap;
717
718    const FIXED_NOW_TS: i64 = 1_700_000_000;
719    const FAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 90 * 24 * 3600;
720
721    fn test_pm_config() -> PortfolioMarginConfig {
722        PortfolioMarginConfig {
723            base_grid: PortfolioMarginGridConfig {
724                scenarios: PortfolioMarginScenario::finalized_default_grid(),
725                base_volatility: 0.8,
726                base_skew: 0.0,
727                base_excess_kurtosis: 0.0,
728                delta_threshold: 0.0001,
729                strike_match_tolerance: 0.01,
730                expiry_match_tolerance_years: 0.001,
731            },
732            symbol_overrides: Vec::new(),
733            contingency: PortfolioMarginContingencyConfig::finalized_default(),
734            risk_free_rate: 0.05,
735        }
736    }
737
738    proptest! {
739        #![proptest_config(ProptestConfig::with_cases(256))]
740
741        #[test]
742        fn equity_equals_cash_plus_option_upnl_plus_perp_upnl(
743            cash in 1000.0f64..1000000.0,
744            spot in 100.0f64..100000.0,
745            opt_quantity in -10.0f64..10.0,
746            perp_quantity in -10.0f64..10.0,
747            iv in 0.3f64..2.0,
748        ) {
749            prop_assume!(opt_quantity.abs() > 0.001);
750
751            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
752            let cash_dec = Decimal::from_f64_retain(cash).unwrap();
753            let opt_qty_dec = Decimal::from_f64_retain(opt_quantity).unwrap();
754            let perp_qty_dec = Decimal::from_f64_retain(perp_quantity).unwrap();
755
756            let strike = spot * 1.1;
757            let strike_dec = Decimal::from_f64_retain(strike).unwrap();
758
759            let key = PortfolioMarginOptionKey {
760                underlying: "BTC".to_string(),
761                option_type: OptionType::Call,
762                strike: strike_dec,
763                expiry_ts: FAR_EXPIRY_TS,
764            };
765
766            let snapshot = PortfolioMarginSnapshot {
767                wallet: test_wallet(204),
768                cash_balance: cash_dec,
769                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
770                    underlying: "BTC".to_string(),
771                    spot_price: spot_dec,
772                    executed_options: vec![PortfolioMarginOptionExposure {
773                        key: key.clone(),
774                        expiry_years: dec!(0.25),
775                        quantity: opt_qty_dec,
776                        entry_price: dec!(100),
777                        source: SnapshotComponentKind::ExecutedPositions,
778                    }],
779                    hypothetical_open_order_options: Vec::new(),
780                    executed_perps: vec![PortfolioMarginPerpExposure {
781                        underlying: "BTC".to_string(),
782                        quantity: perp_qty_dec,
783                        entry_price: Some(spot_dec),
784                        unrealized_pnl: dec!(0),
785                    }],
786                    hypothetical_open_order_perps: Vec::new(),
787                }],
788            };
789
790            let market_state = PortfolioMarginMarketState {
791                config: test_pm_config(),
792                underlyings: HashMap::from([(
793                    "BTC".to_string(),
794                    PortfolioMarginUnderlyingMarketState {
795                        spot_price: spot,
796                        option_inputs: HashMap::from([(
797                            key,
798                            PortfolioMarginOptionMarketState { implied_volatility: iv },
799                        )]),
800                        funding: None,
801                    },
802                )]),
803            };
804
805            let result = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS)
806                .expect("span margin should compute");
807
808            let option_upnl = calculate_net_option_upnl(&snapshot, &market_state)
809                .expect("option upnl should compute");
810            let perp_upnl = calculate_net_perp_upnl(&snapshot, &market_state)
811                .expect("perp upnl should compute");
812
813            let expected_equity = cash + option_upnl + perp_upnl;
814            let actual_equity: f64 = result.equity.to_string().parse().unwrap();
815
816            prop_assert!(
817                (actual_equity - expected_equity).abs() < 1e-4,
818                "equity mismatch: actual={} expected=cash({})+opt_upnl({})+perp_upnl({})={}",
819                actual_equity, cash, option_upnl, perp_upnl, expected_equity
820            );
821        }
822    }
823}
824
825// ===== Properties 12-18: SPAN structural invariants =====
826
827#[cfg(test)]
828mod span_structural_properties {
829    use super::black_scholes::black_scholes_with_moments;
830    use super::portfolio::config::*;
831    use super::portfolio::contingency::calculate_contingency_margin_at;
832    use super::portfolio::evaluator::{calculate_scanning_risk, calculate_scenario_pnl};
833    use super::portfolio::snapshot::*;
834    use super::types::OptionType;
835    use hypercall_types::wallet_address::test_wallet;
836    use proptest::prelude::*;
837    use rust_decimal::Decimal;
838    use rust_decimal_macros::dec;
839    use std::collections::HashMap;
840
841    const FIXED_NOW_TS: i64 = 1_700_000_000;
842    const FAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 90 * 24 * 3600;
843
844    fn test_pm_config() -> PortfolioMarginConfig {
845        PortfolioMarginConfig {
846            base_grid: PortfolioMarginGridConfig {
847                scenarios: PortfolioMarginScenario::finalized_default_grid(),
848                base_volatility: 0.8,
849                base_skew: 0.0,
850                base_excess_kurtosis: 0.0,
851                delta_threshold: 0.0001,
852                strike_match_tolerance: 0.01,
853                expiry_match_tolerance_years: 0.001,
854            },
855            symbol_overrides: Vec::new(),
856            contingency: PortfolioMarginContingencyConfig::finalized_default(),
857            risk_free_rate: 0.05,
858        }
859    }
860
861    fn make_option_key(
862        underlying: &str,
863        option_type: OptionType,
864        strike: Decimal,
865    ) -> PortfolioMarginOptionKey {
866        PortfolioMarginOptionKey {
867            underlying: underlying.to_string(),
868            option_type,
869            strike,
870            expiry_ts: FAR_EXPIRY_TS,
871        }
872    }
873
874    proptest! {
875        #![proptest_config(ProptestConfig::with_cases(256))]
876
877    // ===== Property 12: Put-call parity under scenarios =====
878    // For each scenario: call_pnl - put_pnl ~= perp_pnl (at same strike/expiry).
879    // This verifies the BS repricing is internally consistent across option types.
880
881        #[test]
882        fn put_call_parity_under_scenarios(
883            spot in 100.0f64..50000.0,
884            strike_ratio in 0.8f64..1.2,
885            iv in 0.3f64..1.5,
886        ) {
887            let strike = spot * strike_ratio;
888            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
889            let strike_dec = Decimal::from_f64_retain(strike).unwrap();
890
891            let call_key = make_option_key("BTC", OptionType::Call, strike_dec);
892            let put_key = make_option_key("BTC", OptionType::Put, strike_dec);
893
894            let call_snapshot = PortfolioMarginSnapshot {
895                wallet: test_wallet(210),
896                cash_balance: dec!(0),
897                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
898                    underlying: "BTC".to_string(),
899                    spot_price: spot_dec,
900                    executed_options: vec![PortfolioMarginOptionExposure {
901                        key: call_key.clone(),
902                        expiry_years: dec!(0.25),
903                        quantity: dec!(1),
904                        entry_price: dec!(0),
905                        source: SnapshotComponentKind::ExecutedPositions,
906                    }],
907                    hypothetical_open_order_options: Vec::new(),
908                    executed_perps: Vec::new(),
909                    hypothetical_open_order_perps: Vec::new(),
910                }],
911            };
912
913            let put_snapshot = PortfolioMarginSnapshot {
914                wallet: test_wallet(211),
915                cash_balance: dec!(0),
916                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
917                    underlying: "BTC".to_string(),
918                    spot_price: spot_dec,
919                    executed_options: vec![PortfolioMarginOptionExposure {
920                        key: put_key.clone(),
921                        expiry_years: dec!(0.25),
922                        quantity: dec!(-1),
923                        entry_price: dec!(0),
924                        source: SnapshotComponentKind::ExecutedPositions,
925                    }],
926                    hypothetical_open_order_options: Vec::new(),
927                    executed_perps: Vec::new(),
928                    hypothetical_open_order_perps: Vec::new(),
929                }],
930            };
931
932            let perp_snapshot = PortfolioMarginSnapshot {
933                wallet: test_wallet(212),
934                cash_balance: dec!(0),
935                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
936                    underlying: "BTC".to_string(),
937                    spot_price: spot_dec,
938                    executed_options: Vec::new(),
939                    hypothetical_open_order_options: Vec::new(),
940                    executed_perps: vec![PortfolioMarginPerpExposure {
941                        underlying: "BTC".to_string(),
942                        quantity: dec!(1),
943                        entry_price: None,
944                        unrealized_pnl: dec!(0),
945                    }],
946                    hypothetical_open_order_perps: Vec::new(),
947                }],
948            };
949
950            let market_state = PortfolioMarginMarketState {
951                config: test_pm_config(),
952                underlyings: HashMap::from([(
953                    "BTC".to_string(),
954                    PortfolioMarginUnderlyingMarketState {
955                        spot_price: spot,
956                        option_inputs: HashMap::from([
957                            (call_key, PortfolioMarginOptionMarketState { implied_volatility: iv }),
958                            (put_key, PortfolioMarginOptionMarketState { implied_volatility: iv }),
959                        ]),
960                        funding: None,
961                    },
962                )]),
963            };
964
965            // Test on a pure spot-only scenario (no vol shock) where parity is exact
966            let scenario = PortfolioMarginScenario {
967                id: "parity".to_string(),
968                spot_shock_pct: 0.10,
969                vol_shock_pct: 0.0,
970                pnl_weight: 1.0,
971                is_tail: false,
972            };
973
974            let call_pnl = calculate_scenario_pnl(&call_snapshot, &market_state, &scenario)
975                .expect("call pnl");
976            let put_pnl = calculate_scenario_pnl(&put_snapshot, &market_state, &scenario)
977                .expect("put pnl");
978            let perp_pnl = calculate_scenario_pnl(&perp_snapshot, &market_state, &scenario)
979                .expect("perp pnl");
980
981            // long call + short put = synthetic long ~= perp for spot-only shock
982            let synthetic_pnl = call_pnl + put_pnl;
983            let tolerance = spot * 0.02; // 2% of spot for time value / rate effects
984            prop_assert!(
985                (synthetic_pnl - perp_pnl).abs() < tolerance,
986                "put-call parity violated: call_pnl={} + put_pnl(short)={} = {} vs perp_pnl={} (spot={} strike={})",
987                call_pnl, put_pnl, synthetic_pnl, perp_pnl, spot, strike
988            );
989        }
990
991    // ===== Property 13: Cross-underlying independence =====
992    // margin(BTC + ETH) == margin(BTC) + margin(ETH) -- no cross-netting.
993
994        #[test]
995        fn cross_underlying_margin_is_additive(
996            btc_spot in 10000.0f64..100000.0,
997            eth_spot in 1000.0f64..10000.0,
998            btc_qty in -5.0f64..5.0,
999            eth_qty in -5.0f64..5.0,
1000            iv in 0.3f64..1.5,
1001        ) {
1002            prop_assume!(btc_qty.abs() > 0.01 && eth_qty.abs() > 0.01);
1003
1004            let btc_spot_dec = Decimal::from_f64_retain(btc_spot).unwrap();
1005            let eth_spot_dec = Decimal::from_f64_retain(eth_spot).unwrap();
1006            let btc_strike_dec = Decimal::from_f64_retain(btc_spot * 1.1).unwrap();
1007            let eth_strike_dec = Decimal::from_f64_retain(eth_spot * 1.1).unwrap();
1008            let btc_qty_dec = Decimal::from_f64_retain(btc_qty).unwrap();
1009            let eth_qty_dec = Decimal::from_f64_retain(eth_qty).unwrap();
1010
1011            let btc_key = make_option_key("BTC", OptionType::Call, btc_strike_dec);
1012            let eth_key = make_option_key("ETH", OptionType::Call, eth_strike_dec);
1013
1014            // Combined portfolio
1015            let combined = PortfolioMarginSnapshot {
1016                wallet: test_wallet(220),
1017                cash_balance: dec!(0),
1018                underlyings: vec![
1019                    PortfolioMarginUnderlyingSnapshot {
1020                        underlying: "BTC".to_string(),
1021                        spot_price: btc_spot_dec,
1022                        executed_options: vec![PortfolioMarginOptionExposure {
1023                            key: btc_key.clone(), expiry_years: dec!(0.25),
1024                            quantity: btc_qty_dec, entry_price: dec!(0),
1025                            source: SnapshotComponentKind::ExecutedPositions,
1026                        }],
1027                        hypothetical_open_order_options: Vec::new(),
1028                        executed_perps: Vec::new(),
1029                        hypothetical_open_order_perps: Vec::new(),
1030                    },
1031                    PortfolioMarginUnderlyingSnapshot {
1032                        underlying: "ETH".to_string(),
1033                        spot_price: eth_spot_dec,
1034                        executed_options: vec![PortfolioMarginOptionExposure {
1035                            key: eth_key.clone(), expiry_years: dec!(0.25),
1036                            quantity: eth_qty_dec, entry_price: dec!(0),
1037                            source: SnapshotComponentKind::ExecutedPositions,
1038                        }],
1039                        hypothetical_open_order_options: Vec::new(),
1040                        executed_perps: Vec::new(),
1041                        hypothetical_open_order_perps: Vec::new(),
1042                    },
1043                ],
1044            };
1045
1046            // BTC only
1047            let btc_only = PortfolioMarginSnapshot {
1048                wallet: test_wallet(221),
1049                cash_balance: dec!(0),
1050                underlyings: vec![combined.underlyings[0].clone()],
1051            };
1052
1053            // ETH only
1054            let eth_only = PortfolioMarginSnapshot {
1055                wallet: test_wallet(222),
1056                cash_balance: dec!(0),
1057                underlyings: vec![combined.underlyings[1].clone()],
1058            };
1059
1060            let combined_ms = PortfolioMarginMarketState {
1061                config: test_pm_config(),
1062                underlyings: HashMap::from([
1063                    ("BTC".to_string(), PortfolioMarginUnderlyingMarketState {
1064                        spot_price: btc_spot,
1065                        option_inputs: HashMap::from([(btc_key.clone(), PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1066                        funding: None,
1067                    }),
1068                    ("ETH".to_string(), PortfolioMarginUnderlyingMarketState {
1069                        spot_price: eth_spot,
1070                        option_inputs: HashMap::from([(eth_key.clone(), PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1071                        funding: None,
1072                    }),
1073                ]),
1074            };
1075
1076            let btc_ms = PortfolioMarginMarketState {
1077                config: test_pm_config(),
1078                underlyings: HashMap::from([
1079                    ("BTC".to_string(), PortfolioMarginUnderlyingMarketState {
1080                        spot_price: btc_spot,
1081                        option_inputs: HashMap::from([(btc_key, PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1082                        funding: None,
1083                    }),
1084                ]),
1085            };
1086
1087            let eth_ms = PortfolioMarginMarketState {
1088                config: test_pm_config(),
1089                underlyings: HashMap::from([
1090                    ("ETH".to_string(), PortfolioMarginUnderlyingMarketState {
1091                        spot_price: eth_spot,
1092                        option_inputs: HashMap::from([(eth_key, PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1093                        funding: None,
1094                    }),
1095                ]),
1096            };
1097
1098            let scenarios = super::generate_scenarios(&combined_ms);
1099
1100            let combined_risk = calculate_scanning_risk(&combined, &combined_ms, &scenarios).unwrap();
1101            let btc_risk = calculate_scanning_risk(&btc_only, &btc_ms, &scenarios).unwrap();
1102            let eth_risk = calculate_scanning_risk(&eth_only, &eth_ms, &scenarios).unwrap();
1103
1104            // Scanning risk picks the worst scenario globally, so combined can be
1105            // <= sum (the worst scenario for BTC may not be worst for ETH).
1106            // But combined should never EXCEED the sum -- that would mean cross-netting
1107            // is somehow amplifying risk.
1108            prop_assert!(
1109                combined_risk <= btc_risk + eth_risk + 1e-6,
1110                "cross-underlying amplification: combined={} > btc({}) + eth({}) = {}",
1111                combined_risk, btc_risk, eth_risk, btc_risk + eth_risk
1112            );
1113        }
1114
1115    // ===== Property 14: Open order toggle correctness =====
1116    // Disabling open order contingency should never increase margin.
1117
1118        #[test]
1119        fn disabling_open_order_contingency_never_increases_margin(
1120            spot in 100.0f64..100000.0,
1121            quantity in -10.0f64..-0.01,
1122            near_expiry in prop::bool::ANY,
1123        ) {
1124            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
1125            let qty_dec = Decimal::from_f64_retain(quantity).unwrap();
1126            let expiry_ts = if near_expiry { FIXED_NOW_TS + 12 * 3600 } else { FAR_EXPIRY_TS };
1127
1128            let snapshot = PortfolioMarginSnapshot {
1129                wallet: test_wallet(230),
1130                cash_balance: dec!(100000),
1131                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
1132                    underlying: "BTC".to_string(),
1133                    spot_price: spot_dec,
1134                    executed_options: Vec::new(),
1135                    hypothetical_open_order_options: vec![PortfolioMarginOptionExposure {
1136                        key: PortfolioMarginOptionKey {
1137                            underlying: "BTC".to_string(),
1138                            option_type: OptionType::Call,
1139                            strike: spot_dec,
1140                            expiry_ts,
1141                        },
1142                        expiry_years: dec!(0.01),
1143                        quantity: qty_dec,
1144                        entry_price: dec!(0),
1145                        source: SnapshotComponentKind::OpenOrders,
1146                    }],
1147                    executed_perps: Vec::new(),
1148                    hypothetical_open_order_perps: Vec::new(),
1149                }],
1150            };
1151
1152            let enabled_config = test_pm_config();
1153            let mut disabled_config = test_pm_config();
1154            disabled_config.contingency.apply_floor_to_open_orders = false;
1155            disabled_config.contingency.apply_gamma_to_open_orders = false;
1156
1157            let enabled = calculate_contingency_margin_at(&snapshot, &enabled_config, FIXED_NOW_TS).unwrap();
1158            let disabled = calculate_contingency_margin_at(&snapshot, &disabled_config, FIXED_NOW_TS).unwrap();
1159
1160            prop_assert!(
1161                disabled.option_floor <= enabled.option_floor + 1e-10,
1162                "disabling floor toggle increased floor: disabled={} > enabled={}",
1163                disabled.option_floor, enabled.option_floor
1164            );
1165            prop_assert!(
1166                disabled.gamma_overlay <= enabled.gamma_overlay + 1e-10,
1167                "disabling gamma toggle increased gamma: disabled={} > enabled={}",
1168                disabled.gamma_overlay, enabled.gamma_overlay
1169            );
1170        }
1171
1172    // ===== Property 15: Idempotency =====
1173    // Same input produces identical output across two calls.
1174
1175        #[test]
1176        fn span_margin_is_idempotent(
1177            spot in 100.0f64..100000.0,
1178            quantity in -10.0f64..10.0,
1179            iv in 0.3f64..2.0,
1180        ) {
1181            prop_assume!(quantity.abs() > 0.001);
1182
1183            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
1184            let strike_dec = Decimal::from_f64_retain(spot * 1.1).unwrap();
1185            let qty_dec = Decimal::from_f64_retain(quantity).unwrap();
1186            let key = make_option_key("BTC", OptionType::Call, strike_dec);
1187
1188            let snapshot = PortfolioMarginSnapshot {
1189                wallet: test_wallet(240),
1190                cash_balance: dec!(50000),
1191                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
1192                    underlying: "BTC".to_string(),
1193                    spot_price: spot_dec,
1194                    executed_options: vec![PortfolioMarginOptionExposure {
1195                        key: key.clone(), expiry_years: dec!(0.25),
1196                        quantity: qty_dec, entry_price: dec!(100),
1197                        source: SnapshotComponentKind::ExecutedPositions,
1198                    }],
1199                    hypothetical_open_order_options: Vec::new(),
1200                    executed_perps: Vec::new(),
1201                    hypothetical_open_order_perps: Vec::new(),
1202                }],
1203            };
1204
1205            let market_state = PortfolioMarginMarketState {
1206                config: test_pm_config(),
1207                underlyings: HashMap::from([(
1208                    "BTC".to_string(),
1209                    PortfolioMarginUnderlyingMarketState {
1210                        spot_price: spot,
1211                        option_inputs: HashMap::from([(key, PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1212                        funding: None,
1213                    },
1214                )]),
1215            };
1216
1217            let r1 = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS).unwrap();
1218            let r2 = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS).unwrap();
1219
1220            prop_assert_eq!(r1.scanning_risk, r2.scanning_risk, "scanning_risk differs between calls");
1221            prop_assert_eq!(r1.option_floor, r2.option_floor, "option_floor differs between calls");
1222            prop_assert_eq!(r1.gamma_overlay, r2.gamma_overlay, "gamma_overlay differs between calls");
1223            prop_assert_eq!(r1.equity, r2.equity, "equity differs between calls");
1224            prop_assert_eq!(r1.initial_margin_required, r2.initial_margin_required, "IM differs between calls");
1225            prop_assert_eq!(r1.maintenance_margin_required, r2.maintenance_margin_required, "MM differs between calls");
1226        }
1227
1228    // ===== Property 16: Empty portfolio produces zero margin =====
1229
1230        #[test]
1231        fn empty_portfolio_zero_margin(
1232            cash in 0.0f64..1000000.0,
1233        ) {
1234            let cash_dec = Decimal::from_f64_retain(cash).unwrap();
1235            let snapshot = PortfolioMarginSnapshot {
1236                wallet: test_wallet(250),
1237                cash_balance: cash_dec,
1238                underlyings: Vec::new(),
1239            };
1240
1241            let market_state = PortfolioMarginMarketState {
1242                config: test_pm_config(),
1243                underlyings: HashMap::new(),
1244            };
1245
1246            let scenarios = super::generate_scenarios(&market_state);
1247            let scanning_risk = calculate_scanning_risk(&snapshot, &market_state, &scenarios).unwrap();
1248            let contingency = calculate_contingency_margin_at(&snapshot, &market_state.config, FIXED_NOW_TS).unwrap();
1249
1250            prop_assert_eq!(scanning_risk, 0.0, "empty portfolio has non-zero scanning risk");
1251            prop_assert_eq!(contingency.option_floor, 0.0, "empty portfolio has non-zero floor");
1252            prop_assert_eq!(contingency.gamma_overlay, 0.0, "empty portfolio has non-zero gamma");
1253        }
1254
1255    // ===== Property 17: Standard vs PM directional consistency for naked short =====
1256    // For a single naked short option with no hedges, PM scanning risk should
1257    // not be dramatically lower than standard margin IM. We check PM >= 20% of
1258    // standard IM -- a very loose bound that catches gross miscalculations.
1259
1260        #[test]
1261        fn pm_not_dramatically_below_standard_for_naked_short(
1262            spot in 1000.0f64..100000.0,
1263            strike_ratio in 0.9f64..1.1,
1264            iv in 0.3f64..1.5,
1265        ) {
1266            let strike = spot * strike_ratio;
1267            let spot_dec = Decimal::from_f64_retain(spot).unwrap();
1268            let strike_dec = Decimal::from_f64_retain(strike).unwrap();
1269            let key = make_option_key("BTC", OptionType::Call, strike_dec);
1270
1271            // Standard margin for 1 short call
1272            let std_service = super::StandardMarginService::new();
1273            let mut std_account = super::StandardAccount::new("test".to_string(), dec!(1000000));
1274            std_account.option_positions.push(super::OptionPosition {
1275                symbol: "BTC-C".to_string(),
1276                underlying: "BTC".to_string(),
1277                expiry_ts: FAR_EXPIRY_TS,
1278                strike: strike_dec,
1279                is_call: true,
1280                size: dec!(-1),
1281                mark_price: dec!(0),
1282                entry_price: dec!(0),
1283                spot_price: spot_dec,
1284            });
1285            let std_result = std_service.compute_margin(&std_account);
1286            let std_im: f64 = std_result.position_im.to_string().parse().unwrap();
1287
1288            // PM scanning risk for same position
1289            let snapshot = PortfolioMarginSnapshot {
1290                wallet: test_wallet(250),
1291                cash_balance: dec!(1000000),
1292                underlyings: vec![PortfolioMarginUnderlyingSnapshot {
1293                    underlying: "BTC".to_string(),
1294                    spot_price: spot_dec,
1295                    executed_options: vec![PortfolioMarginOptionExposure {
1296                        key: key.clone(), expiry_years: dec!(0.25),
1297                        quantity: dec!(-1), entry_price: dec!(0),
1298                        source: SnapshotComponentKind::ExecutedPositions,
1299                    }],
1300                    hypothetical_open_order_options: Vec::new(),
1301                    executed_perps: Vec::new(),
1302                    hypothetical_open_order_perps: Vec::new(),
1303                }],
1304            };
1305
1306            let market_state = PortfolioMarginMarketState {
1307                config: test_pm_config(),
1308                underlyings: HashMap::from([(
1309                    "BTC".to_string(),
1310                    PortfolioMarginUnderlyingMarketState {
1311                        spot_price: spot,
1312                        option_inputs: HashMap::from([(key, PortfolioMarginOptionMarketState { implied_volatility: iv })]),
1313                        funding: None,
1314                    },
1315                )]),
1316            };
1317
1318            let pm_result = super::compute_span_margin_at(&snapshot, &market_state, FIXED_NOW_TS).unwrap();
1319            let pm_im: f64 = pm_result.initial_margin_required.to_string().parse().unwrap();
1320
1321            // PM can legitimately be lower (scenario-based vs flat %), but shouldn't
1322            // be dramatically lower. 20% floor is very loose.
1323            prop_assert!(
1324                pm_im >= std_im * 0.20 - 1.0,
1325                "PM margin ({}) is <20% of standard margin ({}) for naked short at spot={} strike={}",
1326                pm_im, std_im, spot, strike
1327            );
1328        }
1329
1330    // ===== Property 18: BS put-call parity (vanilla, exact) =====
1331    // C - P = S - K*exp(-rT), verifying the pricing kernel itself.
1332
1333        #[test]
1334        fn bs_put_call_parity_exact(
1335            spot in 10.0f64..100000.0,
1336            strike in 10.0f64..200000.0,
1337            time in 0.01f64..3.0,
1338            vol in 0.05f64..3.0,
1339            rate in 0.0f64..0.15,
1340        ) {
1341            let call = black_scholes_with_moments(
1342                &OptionType::Call, spot, strike, time, rate, vol, 0.0, 0.0,
1343            );
1344            let put = black_scholes_with_moments(
1345                &OptionType::Put, spot, strike, time, rate, vol, 0.0, 0.0,
1346            );
1347            let forward = spot - strike * (-rate * time).exp();
1348
1349            let diff = (call - put) - forward;
1350            let tolerance = spot * 1e-10;
1351            prop_assert!(
1352                diff.abs() < tolerance,
1353                "put-call parity violated: C({}) - P({}) = {} but S - K*exp(-rT) = {} (diff={})",
1354                call, put, call - put, forward, diff
1355            );
1356        }
1357    }
1358}