Skip to main content

hypercall_margin/portfolio/
snapshot.rs

1use super::config::PortfolioMarginConfig;
2use crate::types::{Account, OptionContract, OptionType, Position};
3use hypercall_types::WalletAddress;
4use rust_decimal::prelude::ToPrimitive;
5use rust_decimal::Decimal;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct PortfolioMarginOptionKey {
10    pub underlying: String,
11    pub option_type: OptionType,
12    pub strike: Decimal,
13    pub expiry_ts: i64,
14}
15
16#[derive(Debug, Clone)]
17pub struct PortfolioMarginOptionExposure {
18    pub key: PortfolioMarginOptionKey,
19    pub expiry_years: Decimal,
20    pub quantity: Decimal,
21    pub entry_price: Decimal,
22    pub source: SnapshotComponentKind,
23}
24
25#[derive(Debug, Clone)]
26pub struct PortfolioMarginPerpExposure {
27    pub underlying: String,
28    pub quantity: Decimal,
29    pub entry_price: Option<Decimal>,
30    pub unrealized_pnl: Decimal,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SnapshotComponentKind {
35    ExecutedPositions,
36    OpenOrders,
37    PerpPositions,
38}
39
40#[derive(Debug, Clone)]
41pub struct PortfolioMarginUnderlyingSnapshot {
42    pub underlying: String,
43    pub spot_price: Decimal,
44    pub executed_options: Vec<PortfolioMarginOptionExposure>,
45    pub hypothetical_open_order_options: Vec<PortfolioMarginOptionExposure>,
46    pub executed_perps: Vec<PortfolioMarginPerpExposure>,
47    pub hypothetical_open_order_perps: Vec<PortfolioMarginPerpExposure>,
48}
49
50#[derive(Debug, Clone)]
51pub struct PortfolioMarginSnapshot {
52    pub wallet: WalletAddress,
53    pub cash_balance: Decimal,
54    pub underlyings: Vec<PortfolioMarginUnderlyingSnapshot>,
55}
56
57#[derive(Debug, Clone)]
58pub struct PortfolioMarginMarketState {
59    pub config: PortfolioMarginConfig,
60    pub underlyings: HashMap<String, PortfolioMarginUnderlyingMarketState>,
61}
62
63#[derive(Debug, Clone)]
64pub struct PortfolioMarginUnderlyingMarketState {
65    pub spot_price: f64,
66    pub option_inputs: HashMap<PortfolioMarginOptionKey, PortfolioMarginOptionMarketState>,
67    pub funding: Option<Decimal>,
68}
69
70#[derive(Debug, Clone)]
71pub struct PortfolioMarginOptionMarketState {
72    pub implied_volatility: f64,
73}
74
75impl PortfolioMarginOptionExposure {
76    /// Build an [`OptionContract`] from this exposure's key and fields.
77    pub fn to_option_contract(&self) -> OptionContract {
78        OptionContract {
79            option_type: self.key.option_type.clone(),
80            strike: self.key.strike,
81            expiry_ts: self.key.expiry_ts,
82            expiry: self.expiry_years,
83            quantity: self.quantity,
84            entry_price: self.entry_price,
85        }
86    }
87}
88
89impl PortfolioMarginSnapshot {
90    /// Build a portfolio-margin snapshot from the legacy margin [`Account`].
91    pub fn from_legacy_account(account: &Account) -> Self {
92        let mut underlyings = Vec::with_capacity(account.portfolio.len());
93        for (underlying, position) in &account.portfolio {
94            let executed_options = position
95                .options
96                .iter()
97                .map(|option| PortfolioMarginOptionExposure {
98                    key: PortfolioMarginOptionKey {
99                        underlying: underlying.clone(),
100                        option_type: option.option_type.clone(),
101                        strike: option.strike,
102                        expiry_ts: option.expiry_ts,
103                    },
104                    expiry_years: option.expiry,
105                    quantity: option.quantity,
106                    entry_price: option.entry_price,
107                    source: SnapshotComponentKind::ExecutedPositions,
108                })
109                .collect();
110            let executed_perps = if position.delta == Decimal::ZERO
111                && position.perp_unrealized_pnl == Decimal::ZERO
112            {
113                Vec::new()
114            } else {
115                vec![PortfolioMarginPerpExposure {
116                    underlying: underlying.clone(),
117                    quantity: position.delta,
118                    entry_price: None,
119                    unrealized_pnl: position.perp_unrealized_pnl,
120                }]
121            };
122
123            underlyings.push(PortfolioMarginUnderlyingSnapshot {
124                underlying: underlying.clone(),
125                spot_price: position.spot,
126                executed_options,
127                hypothetical_open_order_options: Vec::new(),
128                executed_perps,
129                hypothetical_open_order_perps: Vec::new(),
130            });
131        }
132        underlyings.sort_by(|left, right| left.underlying.cmp(&right.underlying));
133
134        Self {
135            wallet: account.id,
136            cash_balance: account_cash_decimal(account),
137            underlyings,
138        }
139    }
140
141    /// Clone this snapshot with all hypothetical open orders removed.
142    /// Underlyings that had only open-order exposures are dropped entirely.
143    pub fn without_open_orders(&self) -> Self {
144        let mut snapshot = self.clone();
145        for underlying in &mut snapshot.underlyings {
146            underlying.hypothetical_open_order_options.clear();
147            underlying.hypothetical_open_order_perps.clear();
148        }
149        snapshot.underlyings.retain(|underlying| {
150            !underlying.executed_options.is_empty() || !underlying.executed_perps.is_empty()
151        });
152        snapshot
153    }
154
155    /// Flatten this snapshot into a legacy [`Account`] for compatibility.
156    ///
157    /// Merges executed and hypothetical open-order exposures. Delta is the sum
158    /// of all perp quantities; `perp_unrealized_pnl` comes from executed perps only.
159    /// Panics on duplicate underlying entries.
160    pub fn to_legacy_account(&self) -> Account {
161        let mut portfolio = HashMap::new();
162
163        for underlying in &self.underlyings {
164            assert!(
165                !portfolio.contains_key(&underlying.underlying),
166                "STATE_CORRUPTION: duplicate underlying {} in snapshot for wallet {}",
167                underlying.underlying,
168                self.wallet
169            );
170            let mut options = Vec::with_capacity(
171                underlying.executed_options.len()
172                    + underlying.hypothetical_open_order_options.len(),
173            );
174            options.extend(
175                underlying
176                    .executed_options
177                    .iter()
178                    .map(PortfolioMarginOptionExposure::to_option_contract),
179            );
180            options.extend(
181                underlying
182                    .hypothetical_open_order_options
183                    .iter()
184                    .map(PortfolioMarginOptionExposure::to_option_contract),
185            );
186
187            let delta = underlying
188                .executed_perps
189                .iter()
190                .chain(underlying.hypothetical_open_order_perps.iter())
191                .fold(Decimal::ZERO, |acc, exposure| acc + exposure.quantity);
192            let perp_unrealized_pnl = underlying
193                .executed_perps
194                .iter()
195                .fold(Decimal::ZERO, |acc, exposure| acc + exposure.unrealized_pnl);
196
197            portfolio.insert(
198                underlying.underlying.clone(),
199                Position {
200                    spot: underlying.spot_price,
201                    delta,
202                    perp_unrealized_pnl,
203                    options,
204                },
205            );
206        }
207
208        Account {
209            id: self.wallet,
210            portfolio,
211            cash: self.cash_balance.to_f64().unwrap_or_else(|| {
212                panic!(
213                    "STATE_CORRUPTION: cash balance {:?} for wallet {} is not representable as f64",
214                    self.cash_balance, self.wallet
215                )
216            }),
217            address: Some(self.wallet),
218        }
219    }
220}
221
222impl PortfolioMarginMarketState {
223    /// Build market state for a snapshot. The caller must populate option IVs.
224    pub fn from_snapshot(
225        snapshot: &PortfolioMarginSnapshot,
226        config: PortfolioMarginConfig,
227    ) -> Self {
228        let underlyings = snapshot
229            .underlyings
230            .iter()
231            .map(|underlying| {
232                (
233                    underlying.underlying.clone(),
234                    PortfolioMarginUnderlyingMarketState {
235                        spot_price: underlying.spot_price.to_f64().unwrap_or_else(|| {
236                            panic!(
237                                "STATE_CORRUPTION: spot {:?} for {} is not representable as f64",
238                                underlying.spot_price, underlying.underlying
239                            )
240                        }),
241                        option_inputs: HashMap::new(),
242                        funding: None,
243                    },
244                )
245            })
246            .collect();
247
248        Self {
249            config,
250            underlyings,
251        }
252    }
253}
254
255pub fn snapshot_from_account(account: &Account) -> PortfolioMarginSnapshot {
256    PortfolioMarginSnapshot::from_legacy_account(account)
257}
258
259pub fn market_state_from_snapshot(
260    snapshot: &PortfolioMarginSnapshot,
261    config: PortfolioMarginConfig,
262) -> PortfolioMarginMarketState {
263    PortfolioMarginMarketState::from_snapshot(snapshot, config)
264}
265
266pub fn account_cash_decimal(account: &Account) -> Decimal {
267    Decimal::from_f64_retain(account.cash).unwrap_or_else(|| {
268        panic!(
269            "STATE_CORRUPTION: account cash {} for {} is not representable as Decimal",
270            account.cash, account.id
271        )
272    })
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use hypercall_types::wallet_address::test_wallet;
279    use rust_decimal_macros::dec;
280
281    #[test]
282    fn snapshot_preserves_executed_perp_upnl_when_delta_is_zero() {
283        let account = Account {
284            id: test_wallet(7),
285            portfolio: HashMap::from([(
286                "BTC".to_string(),
287                Position {
288                    spot: dec!(50000),
289                    delta: dec!(0),
290                    perp_unrealized_pnl: dec!(1250),
291                    options: Vec::new(),
292                },
293            )]),
294            cash: 1000.0,
295            address: None,
296        };
297
298        let snapshot = snapshot_from_account(&account);
299        let btc = snapshot
300            .underlyings
301            .iter()
302            .find(|underlying| underlying.underlying == "BTC")
303            .expect("BTC underlying should be present");
304
305        assert_eq!(btc.executed_perps.len(), 1);
306        assert_eq!(btc.executed_perps[0].quantity, dec!(0));
307        assert_eq!(btc.executed_perps[0].unrealized_pnl, dec!(1250));
308    }
309}