Skip to main content

hypercall_margin/portfolio/
contingency.rs

1use crate::error::MarginError;
2use crate::portfolio::config::PortfolioMarginConfig;
3use crate::portfolio::snapshot::{
4    PortfolioMarginOptionExposure, PortfolioMarginOptionKey, PortfolioMarginSnapshot,
5};
6use hypercall_types::WalletAddress;
7use rust_decimal::prelude::ToPrimitive;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Copy, Default)]
11pub struct ContingencyMargin {
12    pub option_floor: f64,
13    pub gamma_overlay: f64,
14}
15
16pub fn calculate_contingency_margin_at(
17    snapshot: &PortfolioMarginSnapshot,
18    config: &PortfolioMarginConfig,
19    now_ts: i64,
20) -> Result<ContingencyMargin, MarginError> {
21    let mut option_floor = 0.0;
22    let mut gamma_overlay = 0.0;
23
24    for underlying in &snapshot.underlyings {
25        let contingency = config.contingency_for_underlying(&underlying.underlying);
26        let spot_price =
27            underlying
28                .spot_price
29                .to_f64()
30                .ok_or_else(|| MarginError::NonRepresentableDecimal {
31                    field: "spot_price",
32                    underlying: underlying.underlying.clone(),
33                })?;
34        let mut floor_net_by_bucket: HashMap<&PortfolioMarginOptionKey, f64> = HashMap::new();
35        let mut gamma_net_by_bucket: HashMap<&PortfolioMarginOptionKey, f64> = HashMap::new();
36
37        accumulate_exposure(
38            &mut floor_net_by_bucket,
39            &underlying.executed_options,
40            &underlying.underlying,
41        )?;
42        accumulate_exposure(
43            &mut gamma_net_by_bucket,
44            &underlying.executed_options,
45            &underlying.underlying,
46        )?;
47        if contingency.apply_floor_to_open_orders {
48            accumulate_exposure(
49                &mut floor_net_by_bucket,
50                &underlying.hypothetical_open_order_options,
51                &underlying.underlying,
52            )?;
53        }
54        if contingency.apply_gamma_to_open_orders {
55            accumulate_exposure(
56                &mut gamma_net_by_bucket,
57                &underlying.hypothetical_open_order_options,
58                &underlying.underlying,
59            )?;
60        }
61
62        for net_quantity in floor_net_by_bucket.values() {
63            if *net_quantity >= 0.0 {
64                continue;
65            }
66            option_floor += contingency.option_floor_factor * spot_price * net_quantity.abs();
67        }
68
69        for (key, net_quantity) in gamma_net_by_bucket {
70            if net_quantity >= 0.0 {
71                continue;
72            }
73            if key.expiry_ts <= now_ts {
74                continue;
75            }
76
77            let net_short = net_quantity.abs();
78            let expiry_hours =
79                hours_to_expiry(snapshot.wallet, key, &underlying.underlying, now_ts)?;
80            if expiry_hours <= contingency.dte_threshold_hours as f64 {
81                gamma_overlay += contingency.gamma_kicker_factor * spot_price * net_short;
82            }
83        }
84    }
85
86    Ok(ContingencyMargin {
87        option_floor,
88        gamma_overlay,
89    })
90}
91
92fn accumulate_exposure<'a>(
93    net_by_bucket: &mut HashMap<&'a PortfolioMarginOptionKey, f64>,
94    exposures: &'a [PortfolioMarginOptionExposure],
95    underlying: &str,
96) -> Result<(), MarginError> {
97    for exposure in exposures {
98        let quantity =
99            exposure
100                .quantity
101                .to_f64()
102                .ok_or_else(|| MarginError::NonRepresentableDecimal {
103                    field: "quantity",
104                    underlying: underlying.to_string(),
105                })?;
106        *net_by_bucket.entry(&exposure.key).or_insert(0.0) += quantity;
107    }
108    Ok(())
109}
110
111fn hours_to_expiry(
112    wallet: WalletAddress,
113    key: &PortfolioMarginOptionKey,
114    underlying: &str,
115    now_ts: i64,
116) -> Result<f64, MarginError> {
117    let seconds_to_expiry = key.expiry_ts - now_ts;
118    let expiry_hours = seconds_to_expiry as f64 / 3600.0;
119    if !expiry_hours.is_finite() {
120        panic!(
121            "STATE_CORRUPTION: invalid expiry_hours for wallet {} underlying {} expiry_ts {}",
122            wallet, underlying, key.expiry_ts
123        );
124    }
125    Ok(expiry_hours)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::portfolio::config::{
132        PortfolioMarginConfig, PortfolioMarginContingencyConfig, PortfolioMarginGridConfig,
133        PortfolioMarginScenario,
134    };
135    use crate::portfolio::snapshot::{
136        PortfolioMarginOptionExposure, PortfolioMarginOptionKey, PortfolioMarginPerpExposure,
137        PortfolioMarginSnapshot, PortfolioMarginUnderlyingSnapshot, SnapshotComponentKind,
138    };
139    use crate::types::OptionType;
140    use hypercall_types::wallet_address::test_wallet;
141    use rust_decimal_macros::dec;
142
143    const FIXED_NOW_TS: i64 = 1_700_000_000;
144    const NEAR_EXPIRY_OFFSET_SECS: i64 = 24 * 3600;
145    const FAR_EXPIRY_OFFSET_SECS: i64 = 7 * 24 * 3600;
146
147    fn make_config() -> PortfolioMarginConfig {
148        PortfolioMarginConfig {
149            base_grid: PortfolioMarginGridConfig {
150                scenarios: vec![PortfolioMarginScenario {
151                    id: "5".to_string(),
152                    spot_shock_pct: 0.0,
153                    vol_shock_pct: 0.0,
154                    pnl_weight: 1.0,
155                    is_tail: false,
156                }],
157                base_volatility: 0.8,
158                base_skew: 0.0,
159                base_excess_kurtosis: 0.0,
160                delta_threshold: 0.0001,
161                strike_match_tolerance: 0.01,
162                expiry_match_tolerance_years: 0.001,
163            },
164            symbol_overrides: Vec::new(),
165            contingency: PortfolioMarginContingencyConfig {
166                option_floor_factor: 0.015,
167                gamma_kicker_factor: 0.01,
168                dte_threshold_hours: 48,
169                apply_floor_to_open_orders: true,
170                apply_gamma_to_open_orders: true,
171            },
172            risk_free_rate: 0.05,
173        }
174    }
175
176    fn expiry_after(offset_secs: i64) -> i64 {
177        FIXED_NOW_TS + offset_secs
178    }
179
180    fn make_exposure(
181        underlying: &str,
182        strike: rust_decimal::Decimal,
183        expiry_ts: i64,
184        quantity: rust_decimal::Decimal,
185        source: SnapshotComponentKind,
186    ) -> PortfolioMarginOptionExposure {
187        PortfolioMarginOptionExposure {
188            key: PortfolioMarginOptionKey {
189                underlying: underlying.to_string(),
190                option_type: OptionType::Call,
191                strike,
192                expiry_ts,
193            },
194            expiry_years: dec!(0.01),
195            quantity,
196            entry_price: dec!(100),
197            source,
198        }
199    }
200
201    fn make_underlying_snapshot(
202        underlying: &str,
203        spot_price: rust_decimal::Decimal,
204        executed_options: Vec<PortfolioMarginOptionExposure>,
205        hypothetical_open_order_options: Vec<PortfolioMarginOptionExposure>,
206    ) -> PortfolioMarginUnderlyingSnapshot {
207        PortfolioMarginUnderlyingSnapshot {
208            underlying: underlying.to_string(),
209            spot_price,
210            executed_options,
211            hypothetical_open_order_options,
212            executed_perps: Vec::<PortfolioMarginPerpExposure>::new(),
213            hypothetical_open_order_perps: Vec::<PortfolioMarginPerpExposure>::new(),
214        }
215    }
216
217    fn make_snapshot(
218        underlyings: Vec<PortfolioMarginUnderlyingSnapshot>,
219    ) -> PortfolioMarginSnapshot {
220        PortfolioMarginSnapshot {
221            wallet: test_wallet(70),
222            cash_balance: dec!(10000),
223            underlyings,
224        }
225    }
226
227    #[test]
228    fn floor_nets_only_same_strike_bucket() {
229        let config = make_config();
230        let far_expiry = expiry_after(FAR_EXPIRY_OFFSET_SECS);
231        let snapshot = make_snapshot(vec![make_underlying_snapshot(
232            "BTC",
233            dec!(100000),
234            vec![
235                make_exposure(
236                    "BTC",
237                    dec!(100000),
238                    far_expiry,
239                    dec!(-1),
240                    SnapshotComponentKind::ExecutedPositions,
241                ),
242                make_exposure(
243                    "BTC",
244                    dec!(105000),
245                    far_expiry,
246                    dec!(1),
247                    SnapshotComponentKind::ExecutedPositions,
248                ),
249            ],
250            Vec::new(),
251        )]);
252
253        let contingency =
254            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
255        assert_eq!(contingency.option_floor, 1500.0);
256        assert_eq!(contingency.gamma_overlay, 0.0);
257    }
258
259    #[test]
260    fn floor_does_not_net_across_expiries() {
261        let config = make_config();
262        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
263        let far_expiry = expiry_after(FAR_EXPIRY_OFFSET_SECS);
264        let snapshot = make_snapshot(vec![make_underlying_snapshot(
265            "BTC",
266            dec!(100000),
267            vec![
268                make_exposure(
269                    "BTC",
270                    dec!(100000),
271                    near_expiry,
272                    dec!(-1),
273                    SnapshotComponentKind::ExecutedPositions,
274                ),
275                make_exposure(
276                    "BTC",
277                    dec!(100000),
278                    far_expiry,
279                    dec!(1),
280                    SnapshotComponentKind::ExecutedPositions,
281                ),
282            ],
283            Vec::new(),
284        )]);
285
286        let contingency =
287            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
288        assert_eq!(contingency.option_floor, 1500.0);
289        assert_eq!(contingency.gamma_overlay, 1000.0);
290    }
291
292    #[test]
293    fn gamma_overlay_applies_to_near_expiry_net_short() {
294        let config = make_config();
295        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
296        let snapshot = make_snapshot(vec![make_underlying_snapshot(
297            "BTC",
298            dec!(100000),
299            vec![make_exposure(
300                "BTC",
301                dec!(100000),
302                near_expiry,
303                dec!(-2),
304                SnapshotComponentKind::ExecutedPositions,
305            )],
306            Vec::new(),
307        )]);
308
309        let contingency =
310            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
311        assert_eq!(contingency.option_floor, 3000.0);
312        assert_eq!(contingency.gamma_overlay, 2000.0);
313    }
314
315    #[test]
316    fn expired_bucket_does_not_contribute_to_gamma_overlay() {
317        let config = make_config();
318        let snapshot = make_snapshot(vec![make_underlying_snapshot(
319            "BTC",
320            dec!(100000),
321            vec![make_exposure(
322                "BTC",
323                dec!(100000),
324                FIXED_NOW_TS - 1,
325                dec!(-1),
326                SnapshotComponentKind::ExecutedPositions,
327            )],
328            Vec::new(),
329        )]);
330
331        let contingency =
332            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
333        assert_eq!(contingency.option_floor, 1500.0);
334        assert_eq!(contingency.gamma_overlay, 0.0);
335    }
336
337    #[test]
338    fn open_orders_contribute_when_enabled() {
339        let config = make_config();
340        let far_expiry = expiry_after(FAR_EXPIRY_OFFSET_SECS);
341        let snapshot = make_snapshot(vec![make_underlying_snapshot(
342            "BTC",
343            dec!(100000),
344            Vec::new(),
345            vec![make_exposure(
346                "BTC",
347                dec!(100000),
348                far_expiry,
349                dec!(-1),
350                SnapshotComponentKind::OpenOrders,
351            )],
352        )]);
353
354        let contingency =
355            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
356        assert_eq!(contingency.option_floor, 1500.0);
357        assert_eq!(contingency.gamma_overlay, 0.0);
358    }
359
360    #[test]
361    fn open_orders_are_excluded_when_disabled() {
362        let mut config = make_config();
363        config.contingency.apply_floor_to_open_orders = false;
364        config.contingency.apply_gamma_to_open_orders = false;
365        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
366        let snapshot = make_snapshot(vec![make_underlying_snapshot(
367            "BTC",
368            dec!(100000),
369            Vec::new(),
370            vec![make_exposure(
371                "BTC",
372                dec!(100000),
373                near_expiry,
374                dec!(-1),
375                SnapshotComponentKind::OpenOrders,
376            )],
377        )]);
378
379        let contingency =
380            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
381        assert_eq!(contingency.option_floor, 0.0);
382        assert_eq!(contingency.gamma_overlay, 0.0);
383    }
384
385    #[test]
386    fn gamma_overlay_fully_nets_same_strike_and_expiry_bucket() {
387        let config = make_config();
388        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
389        let snapshot = make_snapshot(vec![make_underlying_snapshot(
390            "BTC",
391            dec!(100000),
392            vec![
393                make_exposure(
394                    "BTC",
395                    dec!(100000),
396                    near_expiry,
397                    dec!(-1),
398                    SnapshotComponentKind::ExecutedPositions,
399                ),
400                make_exposure(
401                    "BTC",
402                    dec!(100000),
403                    near_expiry,
404                    dec!(1),
405                    SnapshotComponentKind::ExecutedPositions,
406                ),
407            ],
408            Vec::new(),
409        )]);
410
411        let contingency =
412            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
413        assert_eq!(contingency.option_floor, 0.0);
414        assert_eq!(contingency.gamma_overlay, 0.0);
415    }
416
417    #[test]
418    fn gamma_overlay_does_not_net_across_strikes() {
419        let config = make_config();
420        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
421        let snapshot = make_snapshot(vec![make_underlying_snapshot(
422            "BTC",
423            dec!(100000),
424            vec![
425                make_exposure(
426                    "BTC",
427                    dec!(100000),
428                    near_expiry,
429                    dec!(-1),
430                    SnapshotComponentKind::ExecutedPositions,
431                ),
432                make_exposure(
433                    "BTC",
434                    dec!(105000),
435                    near_expiry,
436                    dec!(1),
437                    SnapshotComponentKind::ExecutedPositions,
438                ),
439            ],
440            Vec::new(),
441        )]);
442
443        let contingency =
444            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
445        assert_eq!(contingency.option_floor, 1500.0);
446        assert_eq!(contingency.gamma_overlay, 1000.0);
447    }
448
449    #[test]
450    fn far_expiry_short_has_floor_without_gamma_overlay() {
451        let config = make_config();
452        let far_expiry = expiry_after(FAR_EXPIRY_OFFSET_SECS);
453        let snapshot = make_snapshot(vec![make_underlying_snapshot(
454            "BTC",
455            dec!(100000),
456            vec![make_exposure(
457                "BTC",
458                dec!(100000),
459                far_expiry,
460                dec!(-1),
461                SnapshotComponentKind::ExecutedPositions,
462            )],
463            Vec::new(),
464        )]);
465
466        let contingency =
467            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
468        assert_eq!(contingency.option_floor, 1500.0);
469        assert_eq!(contingency.gamma_overlay, 0.0);
470    }
471
472    #[test]
473    fn gamma_overlay_sums_per_underlying_without_cross_netting() {
474        let config = make_config();
475        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
476        let snapshot = make_snapshot(vec![
477            make_underlying_snapshot(
478                "BTC",
479                dec!(100000),
480                vec![make_exposure(
481                    "BTC",
482                    dec!(100000),
483                    near_expiry,
484                    dec!(-1),
485                    SnapshotComponentKind::ExecutedPositions,
486                )],
487                Vec::new(),
488            ),
489            make_underlying_snapshot(
490                "ETH",
491                dec!(5000),
492                vec![make_exposure(
493                    "ETH",
494                    dec!(5000),
495                    near_expiry,
496                    dec!(-2),
497                    SnapshotComponentKind::ExecutedPositions,
498                )],
499                Vec::new(),
500            ),
501        ]);
502
503        let contingency =
504            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
505        assert_eq!(contingency.option_floor, 1500.0 + 150.0);
506        assert_eq!(contingency.gamma_overlay, 1000.0 + 100.0);
507    }
508
509    #[test]
510    fn gamma_overlay_respects_open_order_toggle() {
511        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
512        let snapshot = make_snapshot(vec![make_underlying_snapshot(
513            "BTC",
514            dec!(100000),
515            Vec::new(),
516            vec![make_exposure(
517                "BTC",
518                dec!(100000),
519                near_expiry,
520                dec!(-1),
521                SnapshotComponentKind::OpenOrders,
522            )],
523        )]);
524
525        let enabled =
526            calculate_contingency_margin_at(&snapshot, &make_config(), FIXED_NOW_TS).unwrap();
527
528        let mut disabled_config = make_config();
529        disabled_config.contingency.apply_gamma_to_open_orders = false;
530        disabled_config.contingency.apply_floor_to_open_orders = true;
531        let disabled =
532            calculate_contingency_margin_at(&snapshot, &disabled_config, FIXED_NOW_TS).unwrap();
533
534        assert_eq!(enabled.option_floor, 1500.0);
535        assert_eq!(enabled.gamma_overlay, 1000.0);
536        assert_eq!(disabled.option_floor, 1500.0);
537        assert_eq!(disabled.gamma_overlay, 0.0);
538    }
539
540    #[test]
541    fn open_order_long_can_offset_executed_short_when_gamma_toggle_enabled() {
542        let config = make_config();
543        let near_expiry = expiry_after(NEAR_EXPIRY_OFFSET_SECS);
544        let snapshot = make_snapshot(vec![make_underlying_snapshot(
545            "BTC",
546            dec!(100000),
547            vec![make_exposure(
548                "BTC",
549                dec!(100000),
550                near_expiry,
551                dec!(-1),
552                SnapshotComponentKind::ExecutedPositions,
553            )],
554            vec![make_exposure(
555                "BTC",
556                dec!(100000),
557                near_expiry,
558                dec!(1),
559                SnapshotComponentKind::OpenOrders,
560            )],
561        )]);
562
563        let contingency =
564            calculate_contingency_margin_at(&snapshot, &config, FIXED_NOW_TS).unwrap();
565        assert_eq!(contingency.option_floor, 0.0);
566        assert_eq!(contingency.gamma_overlay, 0.0);
567    }
568}