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}