1use crate::portfolio::PortfolioBalance;
17use crate::shared::order_types::ParsedSymbol;
18use crate::types::{Account, OptionContract, Position};
19use hypercall_types::WalletAddress;
20use rust_decimal::Decimal;
21use std::collections::HashMap;
22use std::fmt;
23
24#[derive(Debug, Clone)]
30pub enum BuildAccountError {
31 UnparseableSymbol { symbol: String, reason: String },
33 MissingSpotPrice { underlying: String },
35 UnsupportedInstrument { symbol: String, kind: String },
37 InvalidExpiry { symbol: String, expiry: u64 },
39 InconsistentPosition { symbol: String, details: String },
41}
42
43impl fmt::Display for BuildAccountError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 BuildAccountError::UnparseableSymbol { symbol, reason } => {
47 write!(f, "unparseable symbol '{}': {}", symbol, reason)
48 }
49 BuildAccountError::MissingSpotPrice { underlying } => {
50 write!(f, "missing spot price for underlying '{}'", underlying)
51 }
52 BuildAccountError::UnsupportedInstrument { symbol, kind } => {
53 write!(f, "unsupported instrument '{}' of kind '{}'", symbol, kind)
54 }
55 BuildAccountError::InvalidExpiry { symbol, expiry } => {
56 write!(f, "invalid expiry '{}' in symbol '{}'", expiry, symbol)
57 }
58 BuildAccountError::InconsistentPosition { symbol, details } => {
59 write!(f, "inconsistent position for '{}': {}", symbol, details)
60 }
61 }
62 }
63}
64
65impl std::error::Error for BuildAccountError {}
66
67pub fn build_account_from_balance(
88 account_id: &WalletAddress,
89 balance: &PortfolioBalance,
90 address: Option<WalletAddress>,
91 spot_prices: &HashMap<String, f64>,
92) -> Result<Account, BuildAccountError> {
93 let mut portfolio: HashMap<String, Position> = HashMap::new();
95
96 for (symbol, pos_data) in &balance.positions {
97 let underlying = symbol
98 .strip_suffix("-PERP")
99 .map(ToOwned::to_owned)
100 .or_else(|| {
101 if !symbol.contains('-') && !symbol.is_empty() {
102 Some(symbol.clone())
103 } else {
104 None
105 }
106 });
107 let option_symbol = if underlying.is_some() {
108 None
109 } else {
110 Some(ParsedSymbol::from_symbol(symbol).map_err(|e| {
111 tracing::error!(
112 "Failed to parse symbol {} for account {}: {} - rejecting account build",
113 symbol,
114 account_id,
115 e
116 );
117 BuildAccountError::UnparseableSymbol {
118 symbol: symbol.clone(),
119 reason: e,
120 }
121 })?)
122 };
123
124 let spot_underlying = option_symbol
125 .as_ref()
126 .map(|parsed| parsed.underlying.as_str())
127 .or(underlying.as_deref())
128 .expect("underlying must be present for option or perp position");
129 let spot = spot_prices.get(spot_underlying).copied().ok_or_else(|| {
130 tracing::error!(
131 "Missing spot price for underlying {} (symbol {}) in account {} - rejecting account build",
132 spot_underlying,
133 symbol,
134 account_id
135 );
136 BuildAccountError::MissingSpotPrice {
137 underlying: spot_underlying.to_string(),
138 }
139 })?;
140
141 let position = portfolio
142 .entry(spot_underlying.to_string())
143 .or_insert_with(|| Position {
144 spot: Decimal::from_f64_retain(spot)
145 .expect("validated spot price must be representable as Decimal"),
146 delta: Decimal::ZERO,
147 perp_unrealized_pnl: Decimal::ZERO,
148 options: Vec::new(),
149 });
150
151 if underlying.is_some() {
152 position.delta += pos_data.amount;
153 position.perp_unrealized_pnl += pos_data.unrealized_pnl;
154 continue;
155 }
156
157 let parsed = option_symbol.expect("option symbol must be present for non-perp positions");
158
159 let expiry_years = expiry_to_years(&parsed.underlying, parsed.expiry);
161 let expiry_ts =
162 hypercall_types::expiry_date_to_timestamp(&parsed.underlying, parsed.expiry);
163 if expiry_ts <= 0 {
164 return Err(BuildAccountError::InvalidExpiry {
165 symbol: symbol.clone(),
166 expiry: parsed.expiry,
167 });
168 }
169
170 let option_contract = OptionContract {
173 option_type: parsed.option_type,
174 strike: parsed.strike,
175 expiry_ts,
176 expiry: Decimal::from_f64_retain(expiry_years)
177 .expect("validated option expiry in years must be representable as Decimal"),
178 quantity: pos_data.amount, entry_price: pos_data.entry_price, };
181
182 position.options.push(option_contract);
183 }
184
185 Ok(Account {
186 id: *account_id,
187 portfolio,
188 cash: 0.0, address,
190 })
191}
192
193pub fn expiry_to_years(underlying: &str, expiry_yyyymmdd: u64) -> f64 {
197 use chrono::{DateTime, Utc};
198
199 let expiry_timestamp =
200 match hypercall_types::expiry_date_to_timestamp_checked(underlying, expiry_yyyymmdd) {
201 Ok(timestamp) => timestamp,
202 Err(err) => {
203 tracing::warn!("Invalid expiry date {}: {}", expiry_yyyymmdd, err);
204 return 0.0;
205 }
206 };
207
208 let expiry_datetime = match DateTime::<Utc>::from_timestamp(expiry_timestamp as i64, 0) {
209 Some(datetime) => datetime,
210 None => {
211 tracing::warn!(
212 "Invalid expiry timestamp {} for date {}",
213 expiry_timestamp,
214 expiry_yyyymmdd
215 );
216 return 0.0;
217 }
218 };
219
220 let now = Utc::now();
221 let duration = expiry_datetime.signed_duration_since(now);
222
223 let days = duration.num_seconds() as f64 / 86400.0;
225 let years = days / 365.25;
226
227 if years < 0.0 {
228 0.0 } else {
230 years
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::portfolio::PositionData;
238 use crate::types::OptionType;
239 use hypercall_types::wallet_address::test_wallet;
240 use rust_decimal::prelude::ToPrimitive;
241 use rust_decimal_macros::dec;
242
243 #[test]
244 fn test_build_account_from_empty_balance() {
245 let balance = PortfolioBalance {
246 positions: HashMap::new(),
247 total_margin_used: dec!(0),
248 };
249
250 let spot_prices = HashMap::new();
251 let account = build_account_from_balance(&test_wallet(1), &balance, None, &spot_prices)
252 .expect("empty balance should succeed");
253
254 assert_eq!(account.id, test_wallet(1));
255 assert_eq!(account.cash, 0.0); assert!(account.portfolio.is_empty());
257 assert!(account.address.is_none());
258 }
259
260 #[test]
261 fn test_build_account_with_positions() {
262 let mut positions = HashMap::new();
263 positions.insert(
264 "BTC-20251231-100000-C".to_string(),
265 PositionData {
266 symbol: "BTC-20251231-100000-C".to_string(),
267 amount: dec!(10),
268 entry_price: dec!(5000),
269 margin_posted: dec!(5000),
270 realized_pnl: dec!(0),
271 unrealized_pnl: dec!(0),
272 },
273 );
274 positions.insert(
275 "BTC-20251231-90000-P".to_string(),
276 PositionData {
277 symbol: "BTC-20251231-90000-P".to_string(),
278 amount: dec!(-5), entry_price: dec!(3000),
280 margin_posted: dec!(4500),
281 realized_pnl: dec!(0),
282 unrealized_pnl: dec!(0),
283 },
284 );
285
286 let balance = PortfolioBalance {
287 positions,
288 total_margin_used: dec!(9500),
289 };
290
291 let mut spot_prices = HashMap::new();
292 spot_prices.insert("BTC".to_string(), 95000.0);
293
294 let account = build_account_from_balance(
295 &test_wallet(1),
296 &balance,
297 Some(test_wallet(1)),
298 &spot_prices,
299 )
300 .expect("valid balance with spot prices should succeed");
301
302 assert_eq!(account.id, test_wallet(1));
303 assert_eq!(account.cash, 0.0); assert_eq!(account.address, Some(test_wallet(1)));
305
306 assert_eq!(account.portfolio.len(), 1);
308 let btc_position = account.portfolio.get("BTC").expect("BTC position");
309
310 assert_eq!(btc_position.spot.to_f64().unwrap(), 95000.0);
311 assert_eq!(btc_position.options.len(), 2);
312
313 let call = btc_position
315 .options
316 .iter()
317 .find(|o| o.option_type == OptionType::Call)
318 .expect("call option");
319 assert_eq!(call.strike, dec!(100000));
320 assert_eq!(call.quantity, dec!(10));
321
322 let put = btc_position
324 .options
325 .iter()
326 .find(|o| o.option_type == OptionType::Put)
327 .expect("put option");
328 assert_eq!(put.strike, dec!(90000));
329 assert_eq!(put.quantity, dec!(-5)); }
331
332 #[test]
333 fn test_expiry_to_years() {
334 use chrono::{Datelike, Days, Utc};
335
336 let now = Utc::now().date_naive();
338 let future = now + Days::new(365);
339 let future_expiry =
340 (future.year() as u64) * 10000 + (future.month() as u64) * 100 + future.day() as u64;
341 let years = expiry_to_years("BTC", future_expiry);
342 assert!(
343 years > 0.9 && years < 1.1,
344 "Future expiry ~1 year should be between 0.9 and 1.1, got {}",
345 years
346 );
347
348 let past_expiry = 20200101u64; let years = expiry_to_years("BTC", past_expiry);
351 assert_eq!(years, 0.0, "Past expiry should return 0");
352 }
353
354 #[test]
355 fn test_multiple_underlyings() {
356 let mut positions = HashMap::new();
357 positions.insert(
358 "BTC-20251231-100000-C".to_string(),
359 PositionData {
360 symbol: "BTC-20251231-100000-C".to_string(),
361 amount: dec!(10),
362 entry_price: dec!(5000),
363 margin_posted: dec!(5000),
364 realized_pnl: dec!(0),
365 unrealized_pnl: dec!(0),
366 },
367 );
368 positions.insert(
369 "ETH-20251231-4000-C".to_string(),
370 PositionData {
371 symbol: "ETH-20251231-4000-C".to_string(),
372 amount: dec!(20),
373 entry_price: dec!(200),
374 margin_posted: dec!(4000),
375 realized_pnl: dec!(0),
376 unrealized_pnl: dec!(0),
377 },
378 );
379
380 let balance = PortfolioBalance {
381 positions,
382 total_margin_used: dec!(9000),
383 };
384
385 let mut spot_prices = HashMap::new();
386 spot_prices.insert("BTC".to_string(), 95000.0);
387 spot_prices.insert("ETH".to_string(), 3500.0);
388
389 let account = build_account_from_balance(&test_wallet(1), &balance, None, &spot_prices)
390 .expect("valid multi-underlying balance should succeed");
391
392 assert_eq!(account.portfolio.len(), 2);
394 assert!(account.portfolio.contains_key("BTC"));
395 assert!(account.portfolio.contains_key("ETH"));
396
397 let btc_pos = account.portfolio.get("BTC").unwrap();
398 assert_eq!(btc_pos.spot.to_f64().unwrap(), 95000.0);
399
400 let eth_pos = account.portfolio.get("ETH").unwrap();
401 assert_eq!(eth_pos.spot.to_f64().unwrap(), 3500.0);
402 }
403
404 #[test]
405 fn test_fail_closed_on_unparseable_symbol() {
406 let mut positions = HashMap::new();
407 positions.insert(
408 "INVALID-SYMBOL".to_string(), PositionData {
410 symbol: "INVALID-SYMBOL".to_string(),
411 amount: dec!(10),
412 entry_price: dec!(100),
413 margin_posted: dec!(100),
414 realized_pnl: dec!(0),
415 unrealized_pnl: dec!(0),
416 },
417 );
418
419 let balance = PortfolioBalance {
420 positions,
421 total_margin_used: dec!(100),
422 };
423
424 let spot_prices = HashMap::new();
425 let result = build_account_from_balance(&test_wallet(1), &balance, None, &spot_prices);
426
427 assert!(result.is_err());
428 match result.unwrap_err() {
429 BuildAccountError::UnparseableSymbol { symbol, .. } => {
430 assert_eq!(symbol, "INVALID-SYMBOL");
431 }
432 other => panic!("Expected UnparseableSymbol error, got {:?}", other),
433 }
434 }
435
436 #[test]
437 fn test_fail_closed_on_missing_spot_price() {
438 let mut positions = HashMap::new();
439 positions.insert(
440 "BTC-20251231-100000-C".to_string(),
441 PositionData {
442 symbol: "BTC-20251231-100000-C".to_string(),
443 amount: dec!(10),
444 entry_price: dec!(5000),
445 margin_posted: dec!(5000),
446 realized_pnl: dec!(0),
447 unrealized_pnl: dec!(0),
448 },
449 );
450
451 let balance = PortfolioBalance {
452 positions,
453 total_margin_used: dec!(5000),
454 };
455
456 let spot_prices = HashMap::new();
458 let result = build_account_from_balance(&test_wallet(1), &balance, None, &spot_prices);
459
460 assert!(result.is_err());
461 match result.unwrap_err() {
462 BuildAccountError::MissingSpotPrice { underlying } => {
463 assert_eq!(underlying, "BTC");
464 }
465 other => panic!("Expected MissingSpotPrice error, got {:?}", other),
466 }
467 }
468}