Skip to main content

hypercall_types/
portfolio_greeks.rs

1use std::collections::{BTreeMap, HashMap};
2
3use crate::{Greeks, WalletAddress};
4use rust_decimal::prelude::ToPrimitive;
5use rust_decimal::Decimal;
6use rust_decimal_macros::dec;
7
8use crate::api_models::{
9    PortfolioGreeksAggregate, PortfolioGreeksResponse, PositionGreeksLeg, SimulatedGreeksOrder,
10};
11
12const POSITION_EPSILON: Decimal = dec!(0.00000001);
13const VEGA_WEIGHT_EPSILON: f64 = 1e-12;
14
15pub fn parse_simulated_orders(
16    raw_simulated_orders: Option<&str>,
17) -> Result<Vec<SimulatedGreeksOrder>, String> {
18    let Some(raw) = raw_simulated_orders else {
19        return Ok(Vec::new());
20    };
21    if raw.trim().is_empty() {
22        return Ok(Vec::new());
23    }
24
25    let orders: Vec<SimulatedGreeksOrder> =
26        sonic_rs::from_str(raw).map_err(|e| format!("Invalid simulated_orders JSON: {}", e))?;
27
28    for order in &orders {
29        if order.symbol.trim().is_empty() {
30            return Err("Invalid simulated order: symbol must not be empty".to_string());
31        }
32        if order.size <= Decimal::ZERO {
33            return Err(format!(
34                "Invalid simulated order size for {}: size must be positive",
35                order.symbol
36            ));
37        }
38    }
39
40    Ok(orders)
41}
42
43pub fn build_net_position_quantities<I>(
44    live_positions: I,
45    simulated_orders: &[SimulatedGreeksOrder],
46) -> Result<BTreeMap<String, Decimal>, String>
47where
48    I: IntoIterator<Item = (String, Decimal)>,
49{
50    let mut quantities: HashMap<String, Decimal> = HashMap::new();
51
52    for (symbol, quantity) in live_positions {
53        if quantity.abs() > POSITION_EPSILON {
54            *quantities.entry(symbol).or_insert(Decimal::ZERO) += quantity;
55        }
56    }
57
58    for order in simulated_orders {
59        let signed_size = match order.side {
60            crate::Side::Buy => order.size,
61            crate::Side::Sell => -order.size,
62        };
63        *quantities
64            .entry(order.symbol.clone())
65            .or_insert(Decimal::ZERO) += signed_size;
66    }
67
68    let net = quantities
69        .into_iter()
70        .filter(|(_, quantity)| quantity.abs() > POSITION_EPSILON)
71        .collect::<BTreeMap<_, _>>();
72
73    Ok(net)
74}
75
76pub fn calculate_portfolio_greeks(
77    wallet_address: WalletAddress,
78    net_quantities: &BTreeMap<String, Decimal>,
79    contract_greeks: &HashMap<String, Greeks>,
80) -> Result<PortfolioGreeksResponse, String> {
81    if net_quantities.is_empty() {
82        return Ok(PortfolioGreeksResponse {
83            wallet_address,
84            per_leg: Vec::new(),
85            aggregate: None,
86        });
87    }
88
89    let mut per_leg = Vec::with_capacity(net_quantities.len());
90    let mut total_delta = 0.0;
91    let mut total_gamma = 0.0;
92    let mut total_theta = 0.0;
93    let mut total_vega = 0.0;
94    let mut iv_weighted_sum = 0.0;
95    let mut iv_total_weight = 0.0;
96
97    for (symbol, quantity) in net_quantities {
98        let greeks = contract_greeks
99            .get(symbol)
100            .ok_or_else(|| format!("Missing greeks for symbol {}", symbol))?;
101        let quantity_f64 = quantity
102            .to_f64()
103            .ok_or_else(|| format!("Invalid quantity for symbol {}: {}", symbol, quantity))?;
104
105        let delta = greeks.delta * quantity_f64;
106        let gamma = greeks.gamma * quantity_f64;
107        let theta = greeks.theta * quantity_f64;
108        let vega = greeks.vega * quantity_f64;
109        let iv_weight = vega.abs();
110
111        total_delta += delta;
112        total_gamma += gamma;
113        total_theta += theta;
114        total_vega += vega;
115        iv_weighted_sum += greeks.implied_vol * iv_weight;
116        iv_total_weight += iv_weight;
117
118        per_leg.push(PositionGreeksLeg {
119            symbol: symbol.clone(),
120            quantity: *quantity,
121            delta,
122            gamma,
123            theta,
124            vega,
125            iv: greeks.implied_vol,
126        });
127    }
128
129    let aggregate = PortfolioGreeksAggregate {
130        delta: total_delta,
131        gamma: total_gamma,
132        theta: total_theta,
133        vega: total_vega,
134        iv: if iv_total_weight > VEGA_WEIGHT_EPSILON {
135            Some(iv_weighted_sum / iv_total_weight)
136        } else {
137            None
138        },
139    };
140
141    Ok(PortfolioGreeksResponse {
142        wallet_address,
143        per_leg,
144        aggregate: Some(aggregate),
145    })
146}
147
148#[cfg(test)]
149mod tests {
150    use std::collections::{BTreeMap, HashMap};
151
152    use crate::api_models::SimulatedGreeksOrder;
153    use crate::{wallet_address::test_wallet, Greeks, Side};
154    use rust_decimal_macros::dec;
155
156    use super::{
157        build_net_position_quantities, calculate_portfolio_greeks, parse_simulated_orders,
158    };
159
160    #[test]
161    fn test_parse_simulated_orders_malformed_json() {
162        let err = parse_simulated_orders(Some("{bad json")).expect_err("must fail");
163        assert!(err.contains("Invalid simulated_orders JSON"));
164    }
165
166    #[test]
167    fn test_build_net_quantities_live_and_simulated() {
168        let live = vec![
169            ("BTC-20260131-100000-C".to_string(), dec!(2)),
170            ("ETH-20260131-3000-P".to_string(), dec!(-1)),
171        ];
172        let simulated = vec![
173            SimulatedGreeksOrder {
174                symbol: "BTC-20260131-100000-C".to_string(),
175                side: Side::Sell,
176                size: dec!(1.5),
177            },
178            SimulatedGreeksOrder {
179                symbol: "BTC-20260131-110000-C".to_string(),
180                side: Side::Buy,
181                size: dec!(0.5),
182            },
183            SimulatedGreeksOrder {
184                symbol: "ETH-20260131-3000-P".to_string(),
185                side: Side::Buy,
186                size: dec!(1),
187            },
188        ];
189
190        let net = build_net_position_quantities(live, &simulated).expect("must build");
191        assert_eq!(net.get("BTC-20260131-100000-C"), Some(&dec!(0.5)));
192        assert_eq!(net.get("BTC-20260131-110000-C"), Some(&dec!(0.5)));
193        assert!(
194            !net.contains_key("ETH-20260131-3000-P"),
195            "net zero legs must be dropped"
196        );
197    }
198
199    #[test]
200    fn test_calculate_portfolio_greeks_empty() {
201        let wallet = test_wallet(1);
202        let quantities = BTreeMap::new();
203        let greeks_map = HashMap::new();
204
205        let response =
206            calculate_portfolio_greeks(wallet, &quantities, &greeks_map).expect("must succeed");
207        assert!(response.per_leg.is_empty());
208        assert!(response.aggregate.is_none());
209    }
210
211    #[test]
212    fn test_calculate_portfolio_greeks_vega_weighted_iv() {
213        let wallet = test_wallet(1);
214        let quantities = BTreeMap::from([
215            ("BTC-20260131-100000-C".to_string(), dec!(2)),
216            ("BTC-20260131-110000-C".to_string(), dec!(-1)),
217        ]);
218        let mut greeks_map = HashMap::new();
219        greeks_map.insert(
220            "BTC-20260131-100000-C".to_string(),
221            Greeks {
222                delta: 0.5,
223                gamma: 0.1,
224                theta: -0.05,
225                vega: 0.2,
226                rho: 0.0,
227                implied_vol: 0.6,
228                theoretical_price: 10.0,
229                market_mid_price: None,
230            },
231        );
232        greeks_map.insert(
233            "BTC-20260131-110000-C".to_string(),
234            Greeks {
235                delta: 0.3,
236                gamma: 0.08,
237                theta: -0.03,
238                vega: 0.1,
239                rho: 0.0,
240                implied_vol: 0.4,
241                theoretical_price: 8.0,
242                market_mid_price: None,
243            },
244        );
245
246        let response =
247            calculate_portfolio_greeks(wallet, &quantities, &greeks_map).expect("must succeed");
248        let aggregate = response.aggregate.expect("aggregate must exist");
249
250        assert!((aggregate.delta - 0.7).abs() < 1e-12);
251        assert!((aggregate.gamma - 0.12).abs() < 1e-12);
252        assert!((aggregate.theta + 0.07).abs() < 1e-12);
253        assert!((aggregate.vega - 0.3).abs() < 1e-12);
254        assert!(
255            (aggregate.iv.expect("iv must exist") - 0.56).abs() < 1e-12,
256            "vega-weighted IV should use absolute leg vega weights"
257        );
258    }
259
260    #[test]
261    fn test_calculate_portfolio_greeks_zero_total_vega_yields_null_iv() {
262        let wallet = test_wallet(1);
263        let quantities = BTreeMap::from([("BTC-20260131-100000-C".to_string(), dec!(1))]);
264        let mut greeks_map = HashMap::new();
265        greeks_map.insert(
266            "BTC-20260131-100000-C".to_string(),
267            Greeks {
268                delta: 0.2,
269                gamma: 0.1,
270                theta: -0.01,
271                vega: 0.0,
272                rho: 0.0,
273                implied_vol: 0.5,
274                theoretical_price: 10.0,
275                market_mid_price: None,
276            },
277        );
278
279        let response =
280            calculate_portfolio_greeks(wallet, &quantities, &greeks_map).expect("must succeed");
281        assert!(response
282            .aggregate
283            .expect("aggregate must exist")
284            .iv
285            .is_none());
286    }
287}