Skip to main content

hypercall_margin/portfolio/
span.rs

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
74/// Compute SPAN margin from a fully-populated snapshot and market state.
75///
76/// This is the primary entry point for portfolio margin calculation. The caller
77/// must populate all option IVs in `market_state` before calling -- this crate
78/// has no IO and cannot fetch vol data.
79///
80/// The caller is responsible for populating option IVs in the market state
81/// before calling this function (the margin crate has no IO).
82pub 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/// Scenario PnL entry for risk grid introspection.
107#[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/// P&L for a single instrument under all scenarios.
115#[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/// Extended risk grid with per-instrument breakdown.
126#[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
135/// Compute per-scenario total PnL for a normalized portfolio margin snapshot.
136///
137/// The snapshot is treated as an immutable weighted view of executed positions
138/// plus hypothetical open-order exposure. Missing or non-representable market
139/// inputs are returned as `MarginError`.
140pub 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
156/// Compute per-instrument and aggregate scenario PnL rows for a snapshot.
157///
158/// Uses `PortfolioMarginMarketState` scenarios and underlying market inputs.
159/// Returns `MarginError` for missing option state or decimal values that cannot
160/// be represented for grid math. Empty snapshots return an empty instrument set
161/// with zero aggregate PnL across the configured scenarios.
162pub 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
304/// Detect whether a legacy account has portfolio exposure requiring PM math.
305///
306/// Options always count as live exposure. Perps count when account delta exceeds
307/// `delta_threshold` or when residual perp UPNL is non-zero, so flat but
308/// unsettled accounts are not treated as empty.
309pub 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
331/// Build zero-margin details for a legacy account with no portfolio exposure.
332///
333/// Cash is still carried into equity, while scanning risk, contingency margin,
334/// IM, and MM are all zero. Use only after `has_portfolio_positions` has
335/// determined the account has no PM-relevant positions.
336pub 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}