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}