Skip to main content

hypercall/rsm/portfolio_margin/
account_builder.rs

1//! Account builder for converting PortfolioBalance to types::Account.
2//!
3//! This module provides the translation layer between:
4//! - `PortfolioBalance` (PortfolioService's internal state for executed positions)
5//! - `types::Account` (SPAN/margin calculation input)
6//!
7//! The key insight is that PortfolioBalance stores positions keyed by full symbol
8//! (e.g., "BTC-20250131-100000-C"), while Account groups by underlying (e.g., "BTC").
9//!
10//! ## Fail-Closed Design
11//!
12//! This builder returns `Result<Account, BuildAccountError>` rather than silently
13//! skipping unknown or missing data. If we cannot safely construct a complete
14//! risk view, we return an error so callers can reject margin-sensitive operations.
15
16use 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/// Error type for account construction failures.
25///
26/// When we cannot safely build a risk Account from PortfolioBalance,
27/// we return an error rather than silently skipping risk exposure.
28/// This ensures we "fail closed" - unknown risk = rejected operation.
29#[derive(Debug, Clone)]
30pub enum BuildAccountError {
31    /// Failed to parse a symbol in the portfolio
32    UnparseableSymbol { symbol: String, reason: String },
33    /// Missing spot price for an underlying with positions
34    MissingSpotPrice { underlying: String },
35    /// Instrument type not supported for margin calculation
36    UnsupportedInstrument { symbol: String, kind: String },
37    /// Invalid expiry in a parsed option symbol
38    InvalidExpiry { symbol: String, expiry: u64 },
39    /// Position data is inconsistent or invalid
40    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
67/// Build a `types::Account` from PortfolioService's internal state.
68///
69/// This converts executed positions (PortfolioBalance) into the format
70/// that SPAN/margin code expects (types::Account).
71///
72/// # Arguments
73/// * `account_id` - The wallet address / account identifier
74/// * `balance` - The internal PortfolioBalance from PortfolioService
75/// * `address` - Optional on-chain address (for HyperCore lookups)
76/// * `spot_prices` - Map of underlying -> current spot price (e.g., {"BTC": 100000.0})
77///
78/// # Returns
79/// * `Ok(Account)` - A complete, trustworthy Account ready for SPAN margin calculations
80/// * `Err(BuildAccountError)` - If any position cannot be safely converted
81///
82/// # Fail-Closed Behavior
83/// This function returns an error (rather than skipping) if:
84/// - A symbol cannot be parsed
85/// - A spot price is missing for an underlying with positions
86/// - An instrument type is not supported
87pub 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    // Group positions by underlying
94    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        // Convert expiry from YYYYMMDD to years-to-expiry
160        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        // Create OptionContract with entry_price for UPNL calculation
171        // Keep Decimal values - margin service will convert to f64 for Black-Scholes
172        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, // Positive for long, negative for short
179            entry_price: pos_data.entry_price, // Cost basis per contract
180        };
181
182        position.options.push(option_contract);
183    }
184
185    Ok(Account {
186        id: *account_id,
187        portfolio,
188        cash: 0.0, // Filled by RiskAccountBuilder from its BalanceProvider.
189        address,
190    })
191}
192
193/// Convert YYYYMMDD expiry to years-to-expiry from now.
194///
195/// Returns 0.0 if the expiry is in the past or invalid.
196pub 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    // Convert to years (365.25 days per year)
224    let days = duration.num_seconds() as f64 / 86400.0;
225    let years = days / 365.25;
226
227    if years < 0.0 {
228        0.0 // Expired
229    } 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); // Filled later from RiskAccountBuilder's BalanceProvider.
256        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), // Short
279                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); // Filled later from RiskAccountBuilder's BalanceProvider.
304        assert_eq!(account.address, Some(test_wallet(1)));
305
306        // Should have one Position for BTC underlying
307        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        // Check the call option
314        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        // Check the put option
323        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)); // Short
330    }
331
332    #[test]
333    fn test_expiry_to_years() {
334        use chrono::{Datelike, Days, Utc};
335
336        // Test with a future date (1 year from now)
337        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        // Test with a past date
349        let past_expiry = 20200101u64; // Jan 1, 2020
350        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        // Should have two Positions - one for each underlying
393        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(), // Not a valid option symbol
409            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        // No spot price for BTC - should fail
457        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}