1use crate::black_scholes::black_scholes_with_moments;
2use crate::error::MarginError;
3use crate::portfolio::config::PortfolioMarginScenario;
4use crate::portfolio::snapshot::{
5 PortfolioMarginMarketState, PortfolioMarginSnapshot, PortfolioMarginUnderlyingSnapshot,
6};
7use rust_decimal::prelude::ToPrimitive;
8
9pub fn calculate_scanning_risk(
10 snapshot: &PortfolioMarginSnapshot,
11 market_state: &PortfolioMarginMarketState,
12 scenarios: &[PortfolioMarginScenario],
13) -> Result<f64, MarginError> {
14 assert!(
15 !scenarios.is_empty(),
16 "STATE_CORRUPTION: scanning risk called with empty scenario grid"
17 );
18 let mut worst_loss: f64 = 0.0;
19 for scenario in scenarios {
20 let scenario_pnl = calculate_scenario_pnl(snapshot, market_state, scenario)?;
21 let weighted_pnl = scenario_pnl * scenario.pnl_weight;
22 if !scenario_pnl.is_finite() || !weighted_pnl.is_finite() {
23 panic!(
24 "STATE_CORRUPTION: non-finite PM scenario pnl for {}: raw={}, weighted={}",
25 scenario.id, scenario_pnl, weighted_pnl
26 );
27 }
28 worst_loss = worst_loss.min(weighted_pnl);
29 }
30 Ok(-worst_loss)
31}
32
33pub fn calculate_scenario_pnl(
34 snapshot: &PortfolioMarginSnapshot,
35 market_state: &PortfolioMarginMarketState,
36 scenario: &PortfolioMarginScenario,
37) -> Result<f64, MarginError> {
38 let mut total_pnl = 0.0;
39 for underlying in &snapshot.underlyings {
40 let state = market_state
41 .underlyings
42 .get(&underlying.underlying)
43 .expect("market state missing underlying after prior validation");
44 let adjusted_spot = state.spot_price * (1.0 + scenario.spot_shock_pct);
45 if !adjusted_spot.is_finite() {
46 panic!(
47 "STATE_CORRUPTION: non-finite adjusted spot for {} in scenario {}: {}",
48 underlying.underlying, scenario.id, adjusted_spot
49 );
50 }
51
52 let net_perp_delta = net_perp_delta_f64(underlying)?;
53 if net_perp_delta.abs()
54 > market_state
55 .config
56 .grid_for_underlying(&underlying.underlying)
57 .delta_threshold
58 {
59 total_pnl += net_perp_delta * (adjusted_spot - state.spot_price);
60 }
61
62 for option in underlying
63 .executed_options
64 .iter()
65 .chain(underlying.hypothetical_open_order_options.iter())
66 {
67 let option_state = state
68 .option_inputs
69 .get(&option.key)
70 .expect("option market state missing after prior resolution");
71 let strike = option
72 .key
73 .strike
74 .to_f64()
75 .ok_or_else(|| MarginError::InvalidStrike {
76 underlying: underlying.underlying.clone(),
77 strike: option.key.strike,
78 })?;
79 let expiry = option.expiry_years.to_f64().ok_or_else(|| {
80 MarginError::NonRepresentableDecimal {
81 field: "expiry_years",
82 underlying: underlying.underlying.clone(),
83 }
84 })?;
85 let quantity =
86 option
87 .quantity
88 .to_f64()
89 .ok_or_else(|| MarginError::NonRepresentableDecimal {
90 field: "quantity",
91 underlying: underlying.underlying.clone(),
92 })?;
93 let grid = market_state
94 .config
95 .grid_for_underlying(&underlying.underlying);
96 let adjusted_vol = option_state.implied_volatility * (1.0 + scenario.vol_shock_pct);
97 if !adjusted_vol.is_finite() {
98 panic!(
99 "STATE_CORRUPTION: non-finite adjusted vol for {} in scenario {}: {}",
100 option.key.underlying, scenario.id, adjusted_vol
101 );
102 }
103 let current_value = black_scholes_with_moments(
104 &option.key.option_type,
105 state.spot_price,
106 strike,
107 expiry,
108 market_state.config.risk_free_rate,
109 option_state.implied_volatility,
110 grid.base_skew,
111 grid.base_excess_kurtosis,
112 );
113 let scenario_value = black_scholes_with_moments(
114 &option.key.option_type,
115 adjusted_spot,
116 strike,
117 expiry,
118 market_state.config.risk_free_rate,
119 adjusted_vol,
120 grid.base_skew,
121 grid.base_excess_kurtosis,
122 );
123 if !current_value.is_finite() || !scenario_value.is_finite() {
124 panic!(
125 "STATE_CORRUPTION: non-finite option repricing for {} in scenario {}: current={}, shocked={}",
126 option.key.underlying,
127 scenario.id,
128 current_value,
129 scenario_value
130 );
131 }
132 total_pnl += quantity * (scenario_value - current_value);
133 }
134 }
135 if !total_pnl.is_finite() {
136 panic!(
137 "STATE_CORRUPTION: non-finite total PM scenario pnl for {}: {}",
138 scenario.id, total_pnl
139 );
140 }
141 Ok(total_pnl)
142}
143
144fn net_perp_delta_f64(underlying: &PortfolioMarginUnderlyingSnapshot) -> Result<f64, MarginError> {
145 underlying
146 .executed_perps
147 .iter()
148 .chain(underlying.hypothetical_open_order_perps.iter())
149 .try_fold(0.0, |acc, perp| {
150 let quantity =
151 perp.quantity
152 .to_f64()
153 .ok_or_else(|| MarginError::NonRepresentableDecimal {
154 field: "perp_quantity",
155 underlying: underlying.underlying.clone(),
156 })?;
157 Ok(acc + quantity)
158 })
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::portfolio::config::{
165 PortfolioMarginConfig, PortfolioMarginContingencyConfig, PortfolioMarginGridConfig,
166 PortfolioMarginScenario,
167 };
168 use crate::portfolio::snapshot::{
169 PortfolioMarginOptionExposure, PortfolioMarginOptionKey, PortfolioMarginOptionMarketState,
170 PortfolioMarginPerpExposure, PortfolioMarginUnderlyingMarketState,
171 PortfolioMarginUnderlyingSnapshot, SnapshotComponentKind,
172 };
173 use crate::types::OptionType;
174 use hypercall_types::wallet_address::test_wallet;
175 use rust_decimal_macros::dec;
176 use std::collections::HashMap;
177
178 const FIXED_NOW_TS: i64 = 1_700_000_000;
179 const FAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 90 * 24 * 3600;
180
181 fn test_config() -> PortfolioMarginConfig {
182 PortfolioMarginConfig {
183 base_grid: PortfolioMarginGridConfig {
184 scenarios: Vec::new(),
185 base_volatility: 1.0,
186 base_skew: 0.0,
187 base_excess_kurtosis: 0.0,
188 delta_threshold: 0.0001,
189 strike_match_tolerance: 0.01,
190 expiry_match_tolerance_years: 0.001,
191 },
192 symbol_overrides: Vec::new(),
193 contingency: PortfolioMarginContingencyConfig::finalized_default(),
194 risk_free_rate: 0.0,
195 }
196 }
197
198 fn option_key() -> PortfolioMarginOptionKey {
199 PortfolioMarginOptionKey {
200 underlying: "BTC".to_string(),
201 option_type: OptionType::Call,
202 strike: dec!(100),
203 expiry_ts: FAR_EXPIRY_TS,
204 }
205 }
206
207 fn market_state_for(
208 spot_price: f64,
209 option_inputs: HashMap<PortfolioMarginOptionKey, PortfolioMarginOptionMarketState>,
210 ) -> PortfolioMarginMarketState {
211 PortfolioMarginMarketState {
212 config: test_config(),
213 underlyings: HashMap::from([(
214 "BTC".to_string(),
215 PortfolioMarginUnderlyingMarketState {
216 spot_price,
217 option_inputs,
218 funding: None,
219 },
220 )]),
221 }
222 }
223
224 #[test]
225 fn combined_spot_and_vol_shocks_are_applied_together() {
226 let key = option_key();
227 let snapshot = PortfolioMarginSnapshot {
228 wallet: test_wallet(81),
229 cash_balance: dec!(0),
230 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
231 underlying: "BTC".to_string(),
232 spot_price: dec!(100),
233 executed_options: vec![PortfolioMarginOptionExposure {
234 key: key.clone(),
235 expiry_years: dec!(0.25),
236 quantity: dec!(-1),
237 entry_price: dec!(0),
238 source: SnapshotComponentKind::ExecutedPositions,
239 }],
240 hypothetical_open_order_options: Vec::new(),
241 executed_perps: Vec::new(),
242 hypothetical_open_order_perps: Vec::new(),
243 }],
244 };
245 let market_state = PortfolioMarginMarketState {
246 config: test_config(),
247 underlyings: HashMap::from([(
248 "BTC".to_string(),
249 PortfolioMarginUnderlyingMarketState {
250 spot_price: 100.0,
251 option_inputs: HashMap::from([(
252 key,
253 PortfolioMarginOptionMarketState {
254 implied_volatility: 1.0,
255 },
256 )]),
257 funding: None,
258 },
259 )]),
260 };
261 let spot_only = PortfolioMarginScenario {
262 id: "spot".to_string(),
263 spot_shock_pct: 0.12,
264 vol_shock_pct: 0.0,
265 pnl_weight: 1.0,
266 is_tail: false,
267 };
268 let vol_only = PortfolioMarginScenario {
269 id: "vol".to_string(),
270 spot_shock_pct: 0.0,
271 vol_shock_pct: 0.35,
272 pnl_weight: 1.0,
273 is_tail: false,
274 };
275 let combined = PortfolioMarginScenario {
276 id: "combined".to_string(),
277 spot_shock_pct: 0.12,
278 vol_shock_pct: 0.35,
279 pnl_weight: 1.0,
280 is_tail: false,
281 };
282
283 let spot_only_pnl = calculate_scenario_pnl(&snapshot, &market_state, &spot_only).unwrap();
284 let vol_only_pnl = calculate_scenario_pnl(&snapshot, &market_state, &vol_only).unwrap();
285 let combined_pnl = calculate_scenario_pnl(&snapshot, &market_state, &combined).unwrap();
286
287 assert!(combined_pnl < spot_only_pnl);
288 assert!(combined_pnl < vol_only_pnl);
289 }
290
291 #[test]
292 fn tail_weights_apply_before_worst_case_selection() {
293 let snapshot = PortfolioMarginSnapshot {
294 wallet: test_wallet(82),
295 cash_balance: dec!(0),
296 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
297 underlying: "BTC".to_string(),
298 spot_price: dec!(100),
299 executed_options: Vec::new(),
300 hypothetical_open_order_options: Vec::new(),
301 executed_perps: vec![PortfolioMarginPerpExposure {
302 underlying: "BTC".to_string(),
303 quantity: dec!(1),
304 entry_price: None,
305 unrealized_pnl: dec!(0),
306 }],
307 hypothetical_open_order_perps: Vec::new(),
308 }],
309 };
310 let market_state = PortfolioMarginMarketState {
311 config: test_config(),
312 underlyings: HashMap::from([(
313 "BTC".to_string(),
314 PortfolioMarginUnderlyingMarketState {
315 spot_price: 100.0,
316 option_inputs: HashMap::new(),
317 funding: None,
318 },
319 )]),
320 };
321 let scenarios = vec![
322 PortfolioMarginScenario {
323 id: "core".to_string(),
324 spot_shock_pct: -0.10,
325 vol_shock_pct: 0.0,
326 pnl_weight: 1.0,
327 is_tail: false,
328 },
329 PortfolioMarginScenario {
330 id: "tail".to_string(),
331 spot_shock_pct: -0.40,
332 vol_shock_pct: 0.0,
333 pnl_weight: 0.35,
334 is_tail: true,
335 },
336 ];
337
338 let tail_raw_pnl = calculate_scenario_pnl(&snapshot, &market_state, &scenarios[1]).unwrap();
339 let scanning_risk = calculate_scanning_risk(&snapshot, &market_state, &scenarios).unwrap();
340 assert!((tail_raw_pnl + 40.0).abs() < 1e-9);
341 assert!((scanning_risk - 14.0).abs() < 1e-9);
342 }
343
344 #[test]
345 fn long_straddle_is_more_convex_than_single_call_under_symmetric_shocks() {
346 let call_key = option_key();
347 let put_key = PortfolioMarginOptionKey {
348 option_type: OptionType::Put,
349 ..call_key.clone()
350 };
351 let scenario_up = PortfolioMarginScenario {
352 id: "up".to_string(),
353 spot_shock_pct: 0.15,
354 vol_shock_pct: 0.0,
355 pnl_weight: 1.0,
356 is_tail: false,
357 };
358 let scenario_down = PortfolioMarginScenario {
359 id: "down".to_string(),
360 spot_shock_pct: -0.15,
361 vol_shock_pct: 0.0,
362 pnl_weight: 1.0,
363 is_tail: false,
364 };
365 let market_state = market_state_for(
366 100.0,
367 HashMap::from([
368 (
369 call_key.clone(),
370 PortfolioMarginOptionMarketState {
371 implied_volatility: 1.0,
372 },
373 ),
374 (
375 put_key.clone(),
376 PortfolioMarginOptionMarketState {
377 implied_volatility: 1.0,
378 },
379 ),
380 ]),
381 );
382 let long_call_snapshot = PortfolioMarginSnapshot {
383 wallet: test_wallet(83),
384 cash_balance: dec!(0),
385 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
386 underlying: "BTC".to_string(),
387 spot_price: dec!(100),
388 executed_options: vec![PortfolioMarginOptionExposure {
389 key: call_key.clone(),
390 expiry_years: dec!(0.25),
391 quantity: dec!(1),
392 entry_price: dec!(0),
393 source: SnapshotComponentKind::ExecutedPositions,
394 }],
395 hypothetical_open_order_options: Vec::new(),
396 executed_perps: Vec::new(),
397 hypothetical_open_order_perps: Vec::new(),
398 }],
399 };
400 let long_straddle_snapshot = PortfolioMarginSnapshot {
401 wallet: test_wallet(84),
402 cash_balance: dec!(0),
403 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
404 underlying: "BTC".to_string(),
405 spot_price: dec!(100),
406 executed_options: vec![
407 PortfolioMarginOptionExposure {
408 key: call_key.clone(),
409 expiry_years: dec!(0.25),
410 quantity: dec!(1),
411 entry_price: dec!(0),
412 source: SnapshotComponentKind::ExecutedPositions,
413 },
414 PortfolioMarginOptionExposure {
415 key: put_key.clone(),
416 expiry_years: dec!(0.25),
417 quantity: dec!(1),
418 entry_price: dec!(0),
419 source: SnapshotComponentKind::ExecutedPositions,
420 },
421 ],
422 hypothetical_open_order_options: Vec::new(),
423 executed_perps: Vec::new(),
424 hypothetical_open_order_perps: Vec::new(),
425 }],
426 };
427
428 let call_up = calculate_scenario_pnl(&long_call_snapshot, &market_state, &scenario_up)
429 .expect("long call up scenario should succeed");
430 let call_down =
431 calculate_scenario_pnl(&long_call_snapshot, &market_state, &scenario_down).unwrap();
432 let straddle_up =
433 calculate_scenario_pnl(&long_straddle_snapshot, &market_state, &scenario_up).unwrap();
434 let straddle_down =
435 calculate_scenario_pnl(&long_straddle_snapshot, &market_state, &scenario_down).unwrap();
436
437 assert!(call_up > 0.0);
438 assert!(call_down < 0.0);
439 assert!(straddle_up > 0.0);
440 assert!(straddle_down > call_down);
441 }
442
443 #[test]
444 fn short_perp_hedge_reduces_scanning_risk_for_long_call_book() {
445 let key = option_key();
446 let market_state = market_state_for(
447 100.0,
448 HashMap::from([(
449 key.clone(),
450 PortfolioMarginOptionMarketState {
451 implied_volatility: 1.0,
452 },
453 )]),
454 );
455 let scenarios = vec![
456 PortfolioMarginScenario {
457 id: "up".to_string(),
458 spot_shock_pct: 0.15,
459 vol_shock_pct: 0.0,
460 pnl_weight: 1.0,
461 is_tail: false,
462 },
463 PortfolioMarginScenario {
464 id: "down".to_string(),
465 spot_shock_pct: -0.15,
466 vol_shock_pct: 0.0,
467 pnl_weight: 1.0,
468 is_tail: false,
469 },
470 ];
471 let unhedged = PortfolioMarginSnapshot {
472 wallet: test_wallet(85),
473 cash_balance: dec!(0),
474 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
475 underlying: "BTC".to_string(),
476 spot_price: dec!(100),
477 executed_options: vec![PortfolioMarginOptionExposure {
478 key: key.clone(),
479 expiry_years: dec!(0.25),
480 quantity: dec!(1),
481 entry_price: dec!(0),
482 source: SnapshotComponentKind::ExecutedPositions,
483 }],
484 hypothetical_open_order_options: Vec::new(),
485 executed_perps: Vec::new(),
486 hypothetical_open_order_perps: Vec::new(),
487 }],
488 };
489 let hedged = PortfolioMarginSnapshot {
490 wallet: test_wallet(86),
491 cash_balance: dec!(0),
492 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
493 underlying: "BTC".to_string(),
494 spot_price: dec!(100),
495 executed_options: vec![PortfolioMarginOptionExposure {
496 key,
497 expiry_years: dec!(0.25),
498 quantity: dec!(1),
499 entry_price: dec!(0),
500 source: SnapshotComponentKind::ExecutedPositions,
501 }],
502 hypothetical_open_order_options: Vec::new(),
503 executed_perps: vec![PortfolioMarginPerpExposure {
504 underlying: "BTC".to_string(),
505 quantity: dec!(-0.5),
506 entry_price: None,
507 unrealized_pnl: dec!(0),
508 }],
509 hypothetical_open_order_perps: Vec::new(),
510 }],
511 };
512
513 let unhedged_risk = calculate_scanning_risk(&unhedged, &market_state, &scenarios).unwrap();
514 let hedged_risk = calculate_scanning_risk(&hedged, &market_state, &scenarios).unwrap();
515
516 assert!(hedged_risk < unhedged_risk);
517 }
518
519 #[test]
520 fn vertical_spread_has_lower_scanning_risk_than_naked_short() {
521 let short_key = option_key();
522 let long_key = PortfolioMarginOptionKey {
523 strike: dec!(120),
524 ..short_key.clone()
525 };
526 let scenarios = vec![
527 PortfolioMarginScenario {
528 id: "up".to_string(),
529 spot_shock_pct: 0.20,
530 vol_shock_pct: 0.0,
531 pnl_weight: 1.0,
532 is_tail: false,
533 },
534 PortfolioMarginScenario {
535 id: "down".to_string(),
536 spot_shock_pct: -0.20,
537 vol_shock_pct: 0.0,
538 pnl_weight: 1.0,
539 is_tail: false,
540 },
541 ];
542 let market_state = market_state_for(
543 100.0,
544 HashMap::from([
545 (
546 short_key.clone(),
547 PortfolioMarginOptionMarketState {
548 implied_volatility: 1.0,
549 },
550 ),
551 (
552 long_key.clone(),
553 PortfolioMarginOptionMarketState {
554 implied_volatility: 1.0,
555 },
556 ),
557 ]),
558 );
559 let naked_short = PortfolioMarginSnapshot {
560 wallet: test_wallet(87),
561 cash_balance: dec!(0),
562 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
563 underlying: "BTC".to_string(),
564 spot_price: dec!(100),
565 executed_options: vec![PortfolioMarginOptionExposure {
566 key: short_key.clone(),
567 expiry_years: dec!(0.25),
568 quantity: dec!(-1),
569 entry_price: dec!(0),
570 source: SnapshotComponentKind::ExecutedPositions,
571 }],
572 hypothetical_open_order_options: Vec::new(),
573 executed_perps: Vec::new(),
574 hypothetical_open_order_perps: Vec::new(),
575 }],
576 };
577 let vertical_spread = PortfolioMarginSnapshot {
578 wallet: test_wallet(88),
579 cash_balance: dec!(0),
580 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
581 underlying: "BTC".to_string(),
582 spot_price: dec!(100),
583 executed_options: vec![
584 PortfolioMarginOptionExposure {
585 key: short_key,
586 expiry_years: dec!(0.25),
587 quantity: dec!(-1),
588 entry_price: dec!(0),
589 source: SnapshotComponentKind::ExecutedPositions,
590 },
591 PortfolioMarginOptionExposure {
592 key: long_key,
593 expiry_years: dec!(0.25),
594 quantity: dec!(1),
595 entry_price: dec!(0),
596 source: SnapshotComponentKind::ExecutedPositions,
597 },
598 ],
599 hypothetical_open_order_options: Vec::new(),
600 executed_perps: Vec::new(),
601 hypothetical_open_order_perps: Vec::new(),
602 }],
603 };
604
605 let naked_short_risk =
606 calculate_scanning_risk(&naked_short, &market_state, &scenarios).unwrap();
607 let vertical_spread_risk =
608 calculate_scanning_risk(&vertical_spread, &market_state, &scenarios).unwrap();
609
610 assert!(vertical_spread_risk < naked_short_risk);
611 }
612
613 #[test]
614 fn split_perp_exposures_net_before_threshold() {
615 let mut config = test_config();
616 config.base_grid.delta_threshold = 0.5;
617 let market_state = PortfolioMarginMarketState {
618 config,
619 underlyings: HashMap::from([(
620 "BTC".to_string(),
621 PortfolioMarginUnderlyingMarketState {
622 spot_price: 100.0,
623 option_inputs: HashMap::new(),
624 funding: None,
625 },
626 )]),
627 };
628 let snapshot = PortfolioMarginSnapshot {
629 wallet: test_wallet(84),
630 cash_balance: dec!(0),
631 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
632 underlying: "BTC".to_string(),
633 spot_price: dec!(100),
634 executed_options: Vec::new(),
635 hypothetical_open_order_options: Vec::new(),
636 executed_perps: vec![
637 PortfolioMarginPerpExposure {
638 underlying: "BTC".to_string(),
639 quantity: dec!(0.3),
640 entry_price: Some(dec!(100)),
641 unrealized_pnl: dec!(0),
642 },
643 PortfolioMarginPerpExposure {
644 underlying: "BTC".to_string(),
645 quantity: dec!(0.3),
646 entry_price: Some(dec!(100)),
647 unrealized_pnl: dec!(0),
648 },
649 ],
650 hypothetical_open_order_perps: Vec::new(),
651 }],
652 };
653 let scenario = PortfolioMarginScenario {
654 id: "spot-up".to_string(),
655 spot_shock_pct: 0.1,
656 vol_shock_pct: 0.0,
657 pnl_weight: 1.0,
658 is_tail: false,
659 };
660
661 let pnl = calculate_scenario_pnl(&snapshot, &market_state, &scenario)
662 .expect("split perps should reprice");
663
664 assert!(
665 (pnl - 6.0).abs() < 1e-9,
666 "expected net delta 0.6 to reprice against a $10 spot move, got {}",
667 pnl
668 );
669 }
670}