Skip to main content

hypercall_margin/portfolio/
equity.rs

1use crate::black_scholes::black_scholes_with_moments;
2use crate::error::MarginError;
3use crate::portfolio::snapshot::{PortfolioMarginMarketState, PortfolioMarginSnapshot};
4use rust_decimal::prelude::ToPrimitive;
5
6pub fn calculate_net_option_upnl(
7    snapshot: &PortfolioMarginSnapshot,
8    market_state: &PortfolioMarginMarketState,
9) -> Result<f64, MarginError> {
10    let mut total_upnl = 0.0;
11
12    for underlying in &snapshot.underlyings {
13        let state = market_state
14            .underlyings
15            .get(&underlying.underlying)
16            .expect("market state missing underlying after prior validation");
17        let grid = market_state
18            .config
19            .grid_for_underlying(&underlying.underlying);
20        for option in &underlying.executed_options {
21            let option_state = state
22                .option_inputs
23                .get(&option.key)
24                .expect("option market state missing after prior resolution");
25            let strike = option
26                .key
27                .strike
28                .to_f64()
29                .ok_or_else(|| MarginError::InvalidStrike {
30                    underlying: underlying.underlying.clone(),
31                    strike: option.key.strike,
32                })?;
33            let expiry = option.expiry_years.to_f64().ok_or_else(|| {
34                MarginError::NonRepresentableDecimal {
35                    field: "expiry_years",
36                    underlying: underlying.underlying.clone(),
37                }
38            })?;
39            let quantity =
40                option
41                    .quantity
42                    .to_f64()
43                    .ok_or_else(|| MarginError::NonRepresentableDecimal {
44                        field: "quantity",
45                        underlying: underlying.underlying.clone(),
46                    })?;
47            let entry_price = option.entry_price.to_f64().ok_or_else(|| {
48                MarginError::NonRepresentableDecimal {
49                    field: "entry_price",
50                    underlying: underlying.underlying.clone(),
51                }
52            })?;
53            let current_price = black_scholes_with_moments(
54                &option.key.option_type,
55                state.spot_price,
56                strike,
57                expiry,
58                market_state.config.risk_free_rate,
59                option_state.implied_volatility,
60                grid.base_skew,
61                grid.base_excess_kurtosis,
62            );
63            total_upnl += quantity * (current_price - entry_price);
64        }
65    }
66
67    Ok(total_upnl)
68}
69
70pub fn calculate_net_option_mtm(
71    snapshot: &PortfolioMarginSnapshot,
72    market_state: &PortfolioMarginMarketState,
73) -> Result<f64, MarginError> {
74    let mut total_value = 0.0;
75
76    for underlying in &snapshot.underlyings {
77        let state = market_state
78            .underlyings
79            .get(&underlying.underlying)
80            .expect("market state missing underlying after prior validation");
81        let grid = market_state
82            .config
83            .grid_for_underlying(&underlying.underlying);
84        for option in &underlying.executed_options {
85            let option_state = state
86                .option_inputs
87                .get(&option.key)
88                .expect("option market state missing after prior resolution");
89            let strike = option
90                .key
91                .strike
92                .to_f64()
93                .ok_or_else(|| MarginError::InvalidStrike {
94                    underlying: underlying.underlying.clone(),
95                    strike: option.key.strike,
96                })?;
97            let expiry = option.expiry_years.to_f64().ok_or_else(|| {
98                MarginError::NonRepresentableDecimal {
99                    field: "expiry_years",
100                    underlying: underlying.underlying.clone(),
101                }
102            })?;
103            let quantity =
104                option
105                    .quantity
106                    .to_f64()
107                    .ok_or_else(|| MarginError::NonRepresentableDecimal {
108                        field: "quantity",
109                        underlying: underlying.underlying.clone(),
110                    })?;
111            let value = black_scholes_with_moments(
112                &option.key.option_type,
113                state.spot_price,
114                strike,
115                expiry,
116                market_state.config.risk_free_rate,
117                option_state.implied_volatility,
118                grid.base_skew,
119                grid.base_excess_kurtosis,
120            );
121            total_value += quantity * value;
122        }
123    }
124
125    Ok(total_value)
126}
127
128pub fn calculate_net_perp_upnl(
129    snapshot: &PortfolioMarginSnapshot,
130    market_state: &PortfolioMarginMarketState,
131) -> Result<f64, MarginError> {
132    let mut total_upnl = 0.0;
133
134    for underlying in &snapshot.underlyings {
135        let state = market_state
136            .underlyings
137            .get(&underlying.underlying)
138            .expect("market state missing underlying after prior validation");
139        for perp in &underlying.executed_perps {
140            total_upnl += match perp.entry_price {
141                Some(entry_price) => {
142                    let quantity = perp.quantity.to_f64().ok_or_else(|| {
143                        MarginError::NonRepresentableDecimal {
144                            field: "perp_quantity",
145                            underlying: underlying.underlying.clone(),
146                        }
147                    })?;
148                    let entry_price = entry_price.to_f64().ok_or_else(|| {
149                        MarginError::NonRepresentableDecimal {
150                            field: "perp_entry_price",
151                            underlying: underlying.underlying.clone(),
152                        }
153                    })?;
154                    quantity * (state.spot_price - entry_price)
155                }
156                None => perp.unrealized_pnl.to_f64().ok_or_else(|| {
157                    MarginError::NonRepresentableDecimal {
158                        field: "perp_unrealized_pnl",
159                        underlying: underlying.underlying.clone(),
160                    }
161                })?,
162            };
163        }
164    }
165
166    Ok(total_upnl)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::portfolio::config::{
173        PortfolioMarginConfig, PortfolioMarginContingencyConfig, PortfolioMarginGridConfig,
174        PortfolioMarginSymbolOverride,
175    };
176    use crate::portfolio::snapshot::{
177        PortfolioMarginOptionExposure, PortfolioMarginOptionKey, PortfolioMarginOptionMarketState,
178        PortfolioMarginUnderlyingMarketState, PortfolioMarginUnderlyingSnapshot,
179        SnapshotComponentKind,
180    };
181    use crate::types::OptionType;
182    use hypercall_types::wallet_address::test_wallet;
183    use rust_decimal::Decimal;
184    use rust_decimal_macros::dec;
185    use std::collections::HashMap;
186
187    const FIXED_NOW_TS: i64 = 1_700_000_000;
188    const FAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 90 * 24 * 3600;
189
190    fn market_state_with_override() -> PortfolioMarginMarketState {
191        let key = PortfolioMarginOptionKey {
192            underlying: "BTC".to_string(),
193            option_type: OptionType::Call,
194            strike: dec!(100),
195            expiry_ts: FAR_EXPIRY_TS,
196        };
197
198        PortfolioMarginMarketState {
199            config: PortfolioMarginConfig {
200                base_grid: PortfolioMarginGridConfig {
201                    scenarios: Vec::new(),
202                    base_volatility: 0.8,
203                    base_skew: 0.0,
204                    base_excess_kurtosis: 0.0,
205                    delta_threshold: 0.0001,
206                    strike_match_tolerance: 0.01,
207                    expiry_match_tolerance_years: 0.001,
208                },
209                symbol_overrides: vec![PortfolioMarginSymbolOverride {
210                    underlying: "BTC".to_string(),
211                    grid: PortfolioMarginGridConfig {
212                        scenarios: Vec::new(),
213                        base_volatility: 0.8,
214                        base_skew: 0.45,
215                        base_excess_kurtosis: 1.2,
216                        delta_threshold: 0.0001,
217                        strike_match_tolerance: 0.01,
218                        expiry_match_tolerance_years: 0.001,
219                    },
220                    contingency: None,
221                }],
222                contingency: PortfolioMarginContingencyConfig::finalized_default(),
223                risk_free_rate: 0.01,
224            },
225            underlyings: HashMap::from([(
226                "BTC".to_string(),
227                PortfolioMarginUnderlyingMarketState {
228                    spot_price: 100.0,
229                    option_inputs: HashMap::from([(
230                        key,
231                        PortfolioMarginOptionMarketState {
232                            implied_volatility: 0.9,
233                        },
234                    )]),
235                    funding: None,
236                },
237            )]),
238        }
239    }
240
241    fn snapshot_with_executed_option(entry_price: Decimal) -> PortfolioMarginSnapshot {
242        PortfolioMarginSnapshot {
243            wallet: test_wallet(91),
244            cash_balance: dec!(0),
245            underlyings: vec![PortfolioMarginUnderlyingSnapshot {
246                underlying: "BTC".to_string(),
247                spot_price: dec!(100),
248                executed_options: vec![PortfolioMarginOptionExposure {
249                    key: PortfolioMarginOptionKey {
250                        underlying: "BTC".to_string(),
251                        option_type: OptionType::Call,
252                        strike: dec!(100),
253                        expiry_ts: FAR_EXPIRY_TS,
254                    },
255                    expiry_years: dec!(0.25),
256                    quantity: dec!(1),
257                    entry_price,
258                    source: SnapshotComponentKind::ExecutedPositions,
259                }],
260                hypothetical_open_order_options: Vec::new(),
261                executed_perps: Vec::new(),
262                hypothetical_open_order_perps: Vec::new(),
263            }],
264        }
265    }
266
267    #[test]
268    fn option_mtm_uses_underlying_override_grid() {
269        let market_state = market_state_with_override();
270        let snapshot = snapshot_with_executed_option(dec!(0));
271        let expected = black_scholes_with_moments(
272            &OptionType::Call,
273            100.0,
274            100.0,
275            0.25,
276            market_state.config.risk_free_rate,
277            0.9,
278            0.45,
279            1.2,
280        );
281
282        let actual = calculate_net_option_mtm(&snapshot, &market_state)
283            .expect("option MTM with override should succeed");
284
285        assert!(
286            (actual - expected).abs() < 1e-9,
287            "expected option MTM={} to use underlying override, got {}",
288            expected,
289            actual
290        );
291    }
292
293    #[test]
294    fn option_upnl_uses_underlying_override_grid() {
295        let market_state = market_state_with_override();
296        let current_price = black_scholes_with_moments(
297            &OptionType::Call,
298            100.0,
299            100.0,
300            0.25,
301            market_state.config.risk_free_rate,
302            0.9,
303            0.45,
304            1.2,
305        );
306        let snapshot = snapshot_with_executed_option(
307            Decimal::from_f64_retain(current_price - 2.5)
308                .expect("expected price should be representable"),
309        );
310
311        let actual = calculate_net_option_upnl(&snapshot, &market_state)
312            .expect("option UPNL with override should succeed");
313
314        assert!(
315            (actual - 2.5).abs() < 1e-9,
316            "expected option UPNL=2.5 to use underlying override, got {}",
317            actual
318        );
319    }
320}