hypercall_margin/portfolio/
equity.rs1use 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}