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