1pub 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 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 #[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 #[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#[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 #[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 #[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 #[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 #[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 #[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#[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 #[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 #[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 #[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#[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#[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 #[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 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 let synthetic_pnl = call_pnl + put_pnl;
983 let tolerance = spot * 0.02; 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 #[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 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 let btc_only = PortfolioMarginSnapshot {
1048 wallet: test_wallet(221),
1049 cash_balance: dec!(0),
1050 underlyings: vec![combined.underlyings[0].clone()],
1051 };
1052
1053 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(ð_only, ð_ms, &scenarios).unwrap();
1103
1104 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 #[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 #[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 #[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 #[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 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 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 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 #[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}