Skip to main content

hypercall_margin/portfolio/
evaluator.rs

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}