1use crate::black_scholes::black_scholes_with_moments;
2use crate::constants::MM_TO_IM_RATIO;
3use crate::error::MarginError;
4use crate::portfolio::config::PortfolioMarginScenario;
5use crate::portfolio::contingency::calculate_contingency_margin_at;
6use crate::portfolio::equity::{
7 calculate_net_option_mtm, calculate_net_option_upnl, calculate_net_perp_upnl,
8};
9use crate::portfolio::evaluator::{calculate_scanning_risk, calculate_scenario_pnl};
10use crate::portfolio::snapshot::{
11 account_cash_decimal, PortfolioMarginMarketState, PortfolioMarginOptionKey,
12 PortfolioMarginPerpExposure, PortfolioMarginSnapshot, PortfolioMarginUnderlyingSnapshot,
13};
14use crate::types::{Account, MarginDetails, OptionType};
15use hypercall_types::WalletAddress;
16use rust_decimal::prelude::ToPrimitive;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20fn f64_to_decimal(value: f64, field: &str, wallet: WalletAddress) -> Decimal {
21 Decimal::from_f64_retain(value).unwrap_or_else(|| {
22 panic!(
23 "STATE_CORRUPTION: Failed to convert {} {} to Decimal for account {}. Restart required.",
24 field, value, wallet
25 )
26 })
27}
28
29#[derive(Debug, Clone, Copy)]
30struct MarginComputation {
31 scanning_risk: f64,
32 option_floor: f64,
33 gamma_overlay: f64,
34 net_option_mtm: f64,
35 net_option_upnl: f64,
36 net_perp_upnl: f64,
37}
38
39fn assemble_margin_details(
40 snapshot: &PortfolioMarginSnapshot,
41 margin: MarginComputation,
42) -> MarginDetails {
43 let initial_margin_required =
44 margin.scanning_risk.max(margin.option_floor) + margin.gamma_overlay;
45 let maintenance_margin_required = initial_margin_required * MM_TO_IM_RATIO;
46 let cash_balance = snapshot.cash_balance.to_f64().unwrap_or_else(|| {
47 panic!(
48 "STATE_CORRUPTION: cash_balance {:?} for {} is not representable as f64",
49 snapshot.cash_balance, snapshot.wallet
50 )
51 });
52 let equity = cash_balance + margin.net_option_upnl + margin.net_perp_upnl;
53
54 MarginDetails {
55 account_id: snapshot.wallet,
56 scanning_risk: f64_to_decimal(margin.scanning_risk, "scanning_risk", snapshot.wallet),
57 option_floor: f64_to_decimal(margin.option_floor, "option_floor", snapshot.wallet),
58 gamma_overlay: f64_to_decimal(margin.gamma_overlay, "gamma_overlay", snapshot.wallet),
59 net_option_value: f64_to_decimal(margin.net_option_mtm, "net_option_mtm", snapshot.wallet),
60 equity: f64_to_decimal(equity, "equity", snapshot.wallet),
61 initial_margin_required: f64_to_decimal(
62 initial_margin_required,
63 "initial_margin_required",
64 snapshot.wallet,
65 ),
66 maintenance_margin_required: f64_to_decimal(
67 maintenance_margin_required,
68 "maintenance_margin_required",
69 snapshot.wallet,
70 ),
71 }
72}
73
74pub fn compute_span_margin_at(
83 snapshot: &PortfolioMarginSnapshot,
84 market_state: &PortfolioMarginMarketState,
85 now_ts: i64,
86) -> Result<MarginDetails, MarginError> {
87 let scenarios = generate_scenarios(market_state);
88 let scanning_risk = calculate_scanning_risk(snapshot, market_state, &scenarios)?;
89 let contingency = calculate_contingency_margin_at(snapshot, &market_state.config, now_ts)?;
90 let net_option_mtm = calculate_net_option_mtm(snapshot, market_state)?;
91 let net_option_upnl = calculate_net_option_upnl(snapshot, market_state)?;
92 let net_perp_upnl = calculate_net_perp_upnl(snapshot, market_state)?;
93
94 let margin = MarginComputation {
95 scanning_risk,
96 option_floor: contingency.option_floor,
97 gamma_overlay: contingency.gamma_overlay,
98 net_option_mtm,
99 net_option_upnl,
100 net_perp_upnl,
101 };
102
103 Ok(assemble_margin_details(snapshot, margin))
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ScenarioPnl {
109 pub scenario_id: String,
110 pub scenario: PortfolioMarginScenario,
111 pub total_pnl: f64,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct InstrumentRiskRow {
117 pub symbol: String,
118 pub underlying: String,
119 pub amount: f64,
120 pub base_amount: f64,
121 pub current_value: f64,
122 pub scenario_pnls: Vec<f64>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ExtendedRiskGrid {
128 pub scenarios: Vec<PortfolioMarginScenario>,
129 pub instruments: Vec<InstrumentRiskRow>,
130 pub total_pnls: Vec<f64>,
131 pub worst_scenario_index: usize,
132 pub worst_scenario_pnl: f64,
133}
134
135pub fn compute_risk_grid_from_snapshot(
141 snapshot: &PortfolioMarginSnapshot,
142 market_state: &PortfolioMarginMarketState,
143) -> Result<Vec<ScenarioPnl>, MarginError> {
144 let scenarios = generate_scenarios(market_state);
145 let mut output = Vec::with_capacity(scenarios.len());
146 for scenario in &scenarios {
147 output.push(ScenarioPnl {
148 scenario_id: scenario.id.clone(),
149 scenario: scenario.clone(),
150 total_pnl: calculate_scenario_pnl(snapshot, market_state, scenario)?,
151 });
152 }
153 Ok(output)
154}
155
156pub fn compute_extended_risk_grid_from_snapshot(
163 snapshot: &PortfolioMarginSnapshot,
164 market_state: &PortfolioMarginMarketState,
165) -> Result<ExtendedRiskGrid, MarginError> {
166 let scenarios = generate_scenarios(market_state);
167 let mut instruments = Vec::new();
168 let mut total_pnls = vec![0.0; scenarios.len()];
169
170 for underlying in &snapshot.underlyings {
171 let state = market_state
172 .underlyings
173 .get(&underlying.underlying)
174 .expect("market state missing underlying after prior validation");
175 let grid = market_state
176 .config
177 .grid_for_underlying(&underlying.underlying);
178
179 for option in underlying
180 .executed_options
181 .iter()
182 .chain(underlying.hypothetical_open_order_options.iter())
183 {
184 let option_state = state
185 .option_inputs
186 .get(&option.key)
187 .expect("option market state missing after prior resolution");
188 let strike = option
189 .key
190 .strike
191 .to_f64()
192 .ok_or_else(|| MarginError::InvalidStrike {
193 underlying: underlying.underlying.clone(),
194 strike: option.key.strike,
195 })?;
196 let expiry = option.expiry_years.to_f64().ok_or_else(|| {
197 MarginError::NonRepresentableDecimal {
198 field: "expiry_years",
199 underlying: underlying.underlying.clone(),
200 }
201 })?;
202 let quantity =
203 option
204 .quantity
205 .to_f64()
206 .ok_or_else(|| MarginError::NonRepresentableDecimal {
207 field: "quantity",
208 underlying: underlying.underlying.clone(),
209 })?;
210 let current_value = quantity
211 * black_scholes_with_moments(
212 &option.key.option_type,
213 state.spot_price,
214 strike,
215 expiry,
216 market_state.config.risk_free_rate,
217 option_state.implied_volatility,
218 grid.base_skew,
219 grid.base_excess_kurtosis,
220 );
221 let mut scenario_pnls = Vec::with_capacity(scenarios.len());
222 for (index, scenario) in scenarios.iter().enumerate() {
223 let pnl = calculate_scenario_pnl(
224 &single_option_snapshot(snapshot.wallet, underlying, option.clone()),
225 market_state,
226 scenario,
227 )?;
228 scenario_pnls.push(pnl);
229 total_pnls[index] += pnl;
230 }
231 instruments.push(InstrumentRiskRow {
232 symbol: format_option_symbol(&option.key),
233 underlying: underlying.underlying.clone(),
234 amount: quantity,
235 base_amount: quantity,
236 current_value,
237 scenario_pnls,
238 });
239 }
240
241 let mut net_perp_quantity = 0.0;
242 let mut perp_current_total = 0.0;
243 let mut has_perp_current_value = false;
244 for perp in underlying
245 .executed_perps
246 .iter()
247 .chain(underlying.hypothetical_open_order_perps.iter())
248 {
249 net_perp_quantity +=
250 perp.quantity
251 .to_f64()
252 .ok_or_else(|| MarginError::NonRepresentableDecimal {
253 field: "perp_quantity",
254 underlying: underlying.underlying.clone(),
255 })?;
256 let current_value = perp_current_value(perp, state.spot_price, &underlying.underlying)?;
257 has_perp_current_value |= current_value != 0.0;
258 perp_current_total += current_value;
259 }
260 if net_perp_quantity.abs() > grid.delta_threshold || has_perp_current_value {
261 let mut scenario_pnls = Vec::with_capacity(scenarios.len());
262 for (index, scenario) in scenarios.iter().enumerate() {
263 let adjusted_spot = state.spot_price * (1.0 + scenario.spot_shock_pct);
264 let pnl = net_perp_quantity * (adjusted_spot - state.spot_price);
265 scenario_pnls.push(pnl);
266 total_pnls[index] += pnl;
267 }
268 instruments.push(InstrumentRiskRow {
269 symbol: format!("{}-PERP", underlying.underlying),
270 underlying: underlying.underlying.clone(),
271 amount: net_perp_quantity,
272 base_amount: net_perp_quantity,
273 current_value: perp_current_total,
274 scenario_pnls,
275 });
276 }
277 }
278
279 let (worst_scenario_index, worst_scenario_pnl) = total_pnls
280 .iter()
281 .enumerate()
282 .min_by(|(left_index, left), (right_index, right)| {
283 let left_weighted = **left * scenarios[*left_index].pnl_weight;
284 let right_weighted = **right * scenarios[*right_index].pnl_weight;
285 left_weighted.partial_cmp(&right_weighted).unwrap_or_else(|| {
286 panic!(
287 "STATE_CORRUPTION: non-finite weighted scenario pnl in extended risk grid: left={}, right={}",
288 left_weighted, right_weighted
289 )
290 })
291 })
292 .map(|(index, pnl)| (index, *pnl * scenarios[index].pnl_weight))
293 .unwrap_or((0, 0.0));
294
295 Ok(ExtendedRiskGrid {
296 scenarios,
297 instruments,
298 total_pnls,
299 worst_scenario_index,
300 worst_scenario_pnl,
301 })
302}
303
304pub fn has_portfolio_positions(account: &Account, delta_threshold: f64) -> bool {
310 for position in account.portfolio.values() {
311 if !position.options.is_empty() {
312 return true;
313 }
314
315 let delta_f64 = position.delta.to_f64().unwrap_or_else(|| {
316 panic!(
317 "STATE_CORRUPTION: account delta {:?} for {} is not representable as f64",
318 position.delta, account.id
319 )
320 });
321 if delta_f64.abs() > delta_threshold {
322 return true;
323 }
324 if position.perp_unrealized_pnl != Decimal::ZERO {
325 return true;
326 }
327 }
328 false
329}
330
331pub fn empty_portfolio_margin_details(account: &Account) -> MarginDetails {
337 MarginDetails {
338 account_id: account.id,
339 scanning_risk: Decimal::ZERO,
340 option_floor: Decimal::ZERO,
341 gamma_overlay: Decimal::ZERO,
342 net_option_value: Decimal::ZERO,
343 equity: account_cash_decimal(account),
344 initial_margin_required: Decimal::ZERO,
345 maintenance_margin_required: Decimal::ZERO,
346 }
347}
348
349pub fn generate_scenarios(
350 market_state: &PortfolioMarginMarketState,
351) -> Vec<PortfolioMarginScenario> {
352 market_state.config.base_grid.scenarios.clone()
353}
354
355fn single_option_snapshot(
356 wallet: WalletAddress,
357 underlying: &PortfolioMarginUnderlyingSnapshot,
358 option: crate::portfolio::snapshot::PortfolioMarginOptionExposure,
359) -> PortfolioMarginSnapshot {
360 PortfolioMarginSnapshot {
361 wallet,
362 cash_balance: Decimal::ZERO,
363 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
364 underlying: underlying.underlying.clone(),
365 spot_price: underlying.spot_price,
366 executed_options: vec![option],
367 hypothetical_open_order_options: Vec::new(),
368 executed_perps: Vec::new(),
369 hypothetical_open_order_perps: Vec::new(),
370 }],
371 }
372}
373
374fn perp_current_value(
375 perp: &PortfolioMarginPerpExposure,
376 spot_price: f64,
377 underlying: &str,
378) -> Result<f64, MarginError> {
379 if perp.unrealized_pnl != Decimal::ZERO {
380 return perp
381 .unrealized_pnl
382 .to_f64()
383 .ok_or_else(|| MarginError::NonRepresentableDecimal {
384 field: "perp_unrealized_pnl",
385 underlying: underlying.to_string(),
386 });
387 }
388
389 match perp.entry_price {
390 Some(entry_price) => {
391 let quantity =
392 perp.quantity
393 .to_f64()
394 .ok_or_else(|| MarginError::NonRepresentableDecimal {
395 field: "perp_quantity",
396 underlying: underlying.to_string(),
397 })?;
398 let entry_price =
399 entry_price
400 .to_f64()
401 .ok_or_else(|| MarginError::NonRepresentableDecimal {
402 field: "perp_entry_price",
403 underlying: underlying.to_string(),
404 })?;
405 Ok(quantity * (spot_price - entry_price))
406 }
407 None => Ok(0.0),
408 }
409}
410
411fn format_option_symbol(key: &PortfolioMarginOptionKey) -> String {
412 format!(
413 "{}-{}-{}-{}",
414 key.underlying,
415 format_expiry_from_ts(key.expiry_ts),
416 key.strike.normalize(),
417 match key.option_type {
418 OptionType::Call => "C",
419 OptionType::Put => "P",
420 }
421 )
422}
423
424fn format_expiry_from_ts(expiry_ts: i64) -> String {
425 let days = expiry_ts.div_euclid(86_400);
426 let (year, month, day) = civil_from_days(days);
427 format!("{year:04}{month:02}{day:02}")
428}
429
430fn civil_from_days(days_since_unix_epoch: i64) -> (i64, i64, i64) {
431 let z = days_since_unix_epoch + 719_468;
432 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
433 let day_of_era = z - era * 146_097;
434 let year_of_era =
435 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
436 let year = year_of_era + era * 400;
437 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
438 let month_param = (5 * day_of_year + 2) / 153;
439 let day = day_of_year - (153 * month_param + 2) / 5 + 1;
440 let month = month_param + if month_param < 10 { 3 } else { -9 };
441 let year = year + if month <= 2 { 1 } else { 0 };
442
443 (year, month, day)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::portfolio::{
450 PortfolioMarginConfig, PortfolioMarginMarketState, PortfolioMarginUnderlyingMarketState,
451 };
452 use hypercall_types::wallet_address::test_wallet;
453 use rust_decimal_macros::dec;
454 use std::collections::HashMap;
455
456 #[test]
457 fn extended_grid_keeps_flat_perp_with_residual_upnl() {
458 let snapshot = PortfolioMarginSnapshot {
459 wallet: test_wallet(201),
460 cash_balance: dec!(0),
461 underlyings: vec![PortfolioMarginUnderlyingSnapshot {
462 underlying: "BTC".to_string(),
463 spot_price: dec!(100000),
464 executed_options: Vec::new(),
465 hypothetical_open_order_options: Vec::new(),
466 executed_perps: vec![PortfolioMarginPerpExposure {
467 underlying: "BTC".to_string(),
468 quantity: dec!(0),
469 entry_price: Some(dec!(90000)),
470 unrealized_pnl: dec!(125.50),
471 }],
472 hypothetical_open_order_perps: Vec::new(),
473 }],
474 };
475 let market_state = PortfolioMarginMarketState {
476 config: PortfolioMarginConfig::from_legacy_config(
477 0.05, 0.3, 0.0, 0.0, 0.001, 0.01, 0.001,
478 ),
479 underlyings: HashMap::from([(
480 "BTC".to_string(),
481 PortfolioMarginUnderlyingMarketState {
482 spot_price: 100000.0,
483 option_inputs: HashMap::new(),
484 funding: None,
485 },
486 )]),
487 };
488
489 let grid = compute_extended_risk_grid_from_snapshot(&snapshot, &market_state)
490 .expect("extended grid should compute");
491
492 let perp_row = grid
493 .instruments
494 .iter()
495 .find(|instrument| instrument.symbol == "BTC-PERP")
496 .expect("flat perp with residual UPNL must remain visible");
497 assert_eq!(perp_row.amount, 0.0);
498 assert_eq!(perp_row.base_amount, 0.0);
499 assert_eq!(perp_row.current_value, 125.50);
500 assert!(perp_row.scenario_pnls.iter().all(|pnl| *pnl == 0.0));
501 }
502}