Skip to main content

hypercall/standard_margin/
account_builder.rs

1//! StandardAccountBuilder - Builds StandardAccount from data sources.
2//!
3//! This builder assembles a StandardAccount for margin calculations by combining:
4//! - BalanceProvider: engine-owned USDC balance snapshot
5//! - PortfolioService: Executed positions
6//! - SpotPriceSource: Current spot prices for underlyings
7
8#[cfg(test)]
9use crate::portfolio::PortfolioBalance;
10use crate::portfolio::PortfolioService;
11use crate::rsm::ledger::{BalanceProvider, Ledger, LedgerBalanceProvider, LedgerError};
12use crate::rsm::portfolio_margin::risk_account_builder::SpotPriceSource;
13use crate::shared::order_types::ParsedSymbol;
14use futures::future::join_all;
15use hypercall_margin::standard::{OptionPosition, PerpPosition, StandardAccount};
16use hypercall_types::{expiry_date_to_timestamp, WalletAddress};
17use rust_decimal::Decimal;
18use rust_decimal_macros::dec;
19use std::collections::{BTreeSet, HashMap};
20use std::sync::Arc;
21use tracing::debug;
22
23/// Error type for StandardAccount building.
24#[derive(Debug)]
25pub enum StandardAccountError {
26    Ledger(LedgerError),
27    Parse(String),
28    /// Spot price unavailable for an underlying - cannot safely calculate margin
29    MissingSpotPrice {
30        underlying: String,
31    },
32    /// Unrecognized symbol format - cannot determine if option or perp
33    UnrecognizedSymbol {
34        symbol: String,
35    },
36    /// Spot price f64 value is NaN or Infinity - cannot convert to Decimal
37    InvalidSpotPrice {
38        underlying: String,
39        raw_value: f64,
40    },
41}
42
43impl std::fmt::Display for StandardAccountError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            StandardAccountError::Ledger(e) => write!(f, "ledger error: {}", e),
47            StandardAccountError::Parse(e) => write!(f, "parse error: {}", e),
48            StandardAccountError::MissingSpotPrice { underlying } => {
49                write!(f, "spot price unavailable for {}", underlying)
50            }
51            StandardAccountError::UnrecognizedSymbol { symbol } => {
52                write!(
53                    f,
54                    "unrecognized symbol format '{}': expected option (e.g., BTC-20250131-100000-C) or perp (e.g., BTC-PERP)",
55                    symbol
56                )
57            }
58            StandardAccountError::InvalidSpotPrice {
59                underlying,
60                raw_value,
61            } => {
62                write!(
63                    f,
64                    "invalid spot price for {}: raw value {} is NaN or Infinity",
65                    underlying, raw_value
66                )
67            }
68        }
69    }
70}
71
72impl std::error::Error for StandardAccountError {}
73
74impl From<LedgerError> for StandardAccountError {
75    fn from(e: LedgerError) -> Self {
76        StandardAccountError::Ledger(e)
77    }
78}
79
80/// Builds StandardAccount instances for margin calculations.
81///
82/// Uses the same data sources as RiskAccountBuilder but produces
83/// a simpler account structure optimized for linear margin formulas.
84pub struct StandardAccountBuilder {
85    balance_provider: Arc<dyn BalanceProvider + Send + Sync>,
86    portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
87    spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
88}
89
90impl StandardAccountBuilder {
91    const MIN_POSITION_SIZE: Decimal = dec!(0.00000001);
92    /// Create a new StandardAccountBuilder.
93    pub fn new(
94        ledger: Arc<dyn Ledger + Send + Sync>,
95        portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
96        spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
97    ) -> Self {
98        Self::new_with_balance_provider(
99            Arc::new(LedgerBalanceProvider::new(ledger)),
100            portfolio_service,
101            spot_price_source,
102        )
103    }
104
105    pub fn new_with_balance_provider(
106        balance_provider: Arc<dyn BalanceProvider + Send + Sync>,
107        portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
108        spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
109    ) -> Self {
110        Self {
111            balance_provider,
112            portfolio_service,
113            spot_price_source,
114        }
115    }
116
117    /// Build a StandardAccount for the given wallet.
118    ///
119    /// # Arguments
120    /// * `wallet` - The wallet address
121    ///
122    /// # Returns
123    /// A StandardAccount with USDC balance and all positions.
124    pub async fn build(
125        &self,
126        wallet: &WalletAddress,
127    ) -> Result<StandardAccount, StandardAccountError> {
128        // 1. Get USDC balance from ledger
129        let usdc_balance = self.balance_provider.get_balance(wallet).await?;
130
131        // 2. Get portfolio balance (positions) from PortfolioService
132        let portfolio_balance = self
133            .portfolio_service
134            .get_portfolio_balance(wallet)
135            .await
136            .unwrap_or_default();
137
138        // 3. Balance comes entirely from ledger (faucet deposits + realized PnL)
139        let total_balance = usdc_balance;
140
141        // 4. Build account
142        let mut account = StandardAccount::new(wallet.to_string(), total_balance);
143        let spot_prices = self
144            .fetch_spot_prices_for_balance(&portfolio_balance)
145            .await?;
146
147        // 5. Convert positions
148        for (symbol, pos_data) in &portfolio_balance.positions {
149            // Try to parse as option
150            if let Ok(parsed) = ParsedSymbol::from_symbol(symbol) {
151                let spot_price = *spot_prices.get(&parsed.underlying).ok_or_else(|| {
152                    StandardAccountError::MissingSpotPrice {
153                        underlying: parsed.underlying.clone(),
154                    }
155                })?;
156
157                // Compute mark_price from entry + UPNL/size.
158                // When UPNL is zero (stale after restart replay), fall back to
159                // intrinsic value so equity isn't inflated by the full entry premium.
160                let mark_price = if pos_data.amount.abs() < Self::MIN_POSITION_SIZE {
161                    pos_data.entry_price
162                } else if pos_data.unrealized_pnl == dec!(0) {
163                    // UPNL hasn't been refreshed yet (likely after restart).
164                    // Use intrinsic value as a conservative mark estimate.
165                    let spot_dec = spot_price;
166                    let strike_dec = parsed.strike;
167                    let intrinsic = if matches!(parsed.option_type, crate::types::OptionType::Call)
168                    {
169                        spot_dec - strike_dec
170                    } else {
171                        strike_dec - spot_dec
172                    };
173                    intrinsic.max(dec!(0))
174                } else {
175                    pos_data.entry_price + pos_data.unrealized_pnl / pos_data.amount
176                };
177
178                let option_pos = OptionPosition {
179                    symbol: symbol.clone(),
180                    underlying: parsed.underlying.clone(),
181                    expiry_ts: expiry_date_to_timestamp(&parsed.underlying, parsed.expiry),
182                    strike: parsed.strike,
183                    is_call: matches!(parsed.option_type, crate::types::OptionType::Call),
184                    size: pos_data.amount,
185                    mark_price,
186                    entry_price: pos_data.entry_price,
187                    spot_price,
188                };
189
190                account.option_positions.push(option_pos);
191            } else if symbol.ends_with("-PERP") {
192                // It's a perpetual
193                let underlying = symbol.trim_end_matches("-PERP").to_string();
194                let spot_price = *spot_prices.get(&underlying).ok_or_else(|| {
195                    StandardAccountError::MissingSpotPrice {
196                        underlying: underlying.clone(),
197                    }
198                })?;
199
200                let perp_pos = PerpPosition {
201                    symbol: symbol.clone(),
202                    underlying,
203                    size: pos_data.amount,
204                    mark_price: spot_price, // Perp mark ≈ spot
205                    entry_price: pos_data.entry_price,
206                };
207
208                account.perp_positions.push(perp_pos);
209            } else {
210                // Fail-safe: return an error for unrecognized symbols instead of silently dropping
211                return Err(StandardAccountError::UnrecognizedSymbol {
212                    symbol: symbol.clone(),
213                });
214            }
215        }
216
217        debug!(
218            "StandardAccountBuilder: built account for {}: balance={:.2}, options={}, perps={}",
219            wallet,
220            account.usdc_balance,
221            account.option_positions.len(),
222            account.perp_positions.len()
223        );
224
225        Ok(account)
226    }
227
228    /// Build a StandardAccount synchronously from engine-owned state.
229    ///
230    /// Used by the engine's margin check path where all data is already
231    /// in balance_ledger and engine_positions.
232    pub fn build_from_engine_state(
233        wallet: &WalletAddress,
234        balance_ledger: &HashMap<WalletAddress, Decimal>,
235        engine_positions: &HashMap<
236            (WalletAddress, String),
237            crate::rsm::engine_deps::EnginePosition,
238        >,
239        reference_prices: &HashMap<String, f64>,
240    ) -> Result<StandardAccount, StandardAccountError> {
241        let usdc_balance = balance_ledger.get(wallet).copied().unwrap_or(Decimal::ZERO);
242        let mut account = StandardAccount::new(wallet.to_string(), usdc_balance);
243
244        for ((w, symbol), pos) in engine_positions {
245            if w != wallet {
246                continue;
247            }
248            if pos.quantity.abs() < Self::MIN_POSITION_SIZE {
249                continue;
250            }
251
252            if let Ok(parsed) = ParsedSymbol::from_symbol(symbol) {
253                let spot_f64 = reference_prices.get(&parsed.underlying).ok_or_else(|| {
254                    StandardAccountError::MissingSpotPrice {
255                        underlying: parsed.underlying.clone(),
256                    }
257                })?;
258                let spot_price = Decimal::from_f64_retain(*spot_f64).ok_or_else(|| {
259                    StandardAccountError::InvalidSpotPrice {
260                        underlying: parsed.underlying.clone(),
261                        raw_value: *spot_f64,
262                    }
263                })?;
264
265                let intrinsic = if matches!(parsed.option_type, crate::types::OptionType::Call) {
266                    spot_price - parsed.strike
267                } else {
268                    parsed.strike - spot_price
269                };
270                let mark_price = intrinsic.max(dec!(0));
271
272                account.option_positions.push(OptionPosition {
273                    symbol: symbol.clone(),
274                    underlying: parsed.underlying.clone(),
275                    expiry_ts: expiry_date_to_timestamp(&parsed.underlying, parsed.expiry),
276                    strike: parsed.strike,
277                    is_call: matches!(parsed.option_type, crate::types::OptionType::Call),
278                    size: pos.quantity,
279                    mark_price,
280                    entry_price: pos.entry_price,
281                    spot_price,
282                });
283            } else if symbol.ends_with("-PERP") {
284                let underlying = symbol.trim_end_matches("-PERP").to_string();
285                let spot_f64 = reference_prices.get(&underlying).ok_or_else(|| {
286                    StandardAccountError::MissingSpotPrice {
287                        underlying: underlying.clone(),
288                    }
289                })?;
290                let spot_price = Decimal::from_f64_retain(*spot_f64).ok_or_else(|| {
291                    StandardAccountError::InvalidSpotPrice {
292                        underlying: underlying.clone(),
293                        raw_value: *spot_f64,
294                    }
295                })?;
296
297                account.perp_positions.push(PerpPosition {
298                    symbol: symbol.clone(),
299                    underlying,
300                    size: pos.quantity,
301                    mark_price: spot_price,
302                    entry_price: pos.entry_price,
303                });
304            } else {
305                return Err(StandardAccountError::UnrecognizedSymbol {
306                    symbol: symbol.clone(),
307                });
308            }
309        }
310
311        Ok(account)
312    }
313
314    async fn fetch_spot_prices_for_balance(
315        &self,
316        portfolio_balance: &crate::portfolio::PortfolioBalance,
317    ) -> Result<HashMap<String, Decimal>, StandardAccountError> {
318        let mut underlyings = BTreeSet::new();
319        for symbol in portfolio_balance.positions.keys() {
320            if let Ok(parsed) = ParsedSymbol::from_symbol(symbol) {
321                underlyings.insert(parsed.underlying);
322            } else if let Some(underlying) = symbol.strip_suffix("-PERP") {
323                underlyings.insert(underlying.to_string());
324            } else {
325                return Err(StandardAccountError::UnrecognizedSymbol {
326                    symbol: symbol.clone(),
327                });
328            }
329        }
330
331        let fetches = underlyings.into_iter().map(|underlying| {
332            let spot_price_source = self.spot_price_source.clone();
333            async move {
334                let spot_price_f64 = spot_price_source
335                    .get_spot_price(&underlying)
336                    .await
337                    .ok_or_else(|| StandardAccountError::MissingSpotPrice {
338                        underlying: underlying.clone(),
339                    })?;
340                let spot_price = Decimal::from_f64_retain(spot_price_f64).ok_or_else(|| {
341                    StandardAccountError::InvalidSpotPrice {
342                        underlying: underlying.clone(),
343                        raw_value: spot_price_f64,
344                    }
345                })?;
346                Ok::<(String, Decimal), StandardAccountError>((underlying, spot_price))
347            }
348        });
349
350        let mut spot_prices = HashMap::new();
351        for result in join_all(fetches).await {
352            let (underlying, spot_price) = result?;
353            spot_prices.insert(underlying, spot_price);
354        }
355
356        Ok(spot_prices)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::portfolio::PositionData;
364    use crate::rsm::ledger::InMemoryLedger;
365    use crate::standard_margin::service::StandardMarginService;
366    use async_trait::async_trait;
367    use hypercall_types::wallet_address::test_wallet;
368    use std::collections::HashMap;
369    use tokio::sync::RwLock;
370
371    // Mock PortfolioService for testing
372    struct MockPortfolioService {
373        balances: RwLock<HashMap<String, PortfolioBalance>>,
374    }
375
376    #[async_trait]
377    impl PortfolioService for MockPortfolioService {
378        async fn get_portfolio(
379            &self,
380            _account: &WalletAddress,
381        ) -> hypercall_types::api_models::Portfolio {
382            unimplemented!()
383        }
384
385        async fn get_portfolio_balance(&self, account: &WalletAddress) -> Option<PortfolioBalance> {
386            let balances = self.balances.read().await;
387            balances.get(&account.as_hex().to_lowercase()).cloned()
388        }
389
390        async fn all_portfolios(&self) -> HashMap<WalletAddress, PortfolioBalance> {
391            // This mock uses String keys, but we need to return WalletAddress keys
392            // For testing purposes, return empty since this is a mock
393            HashMap::new()
394        }
395
396        async fn apply_event(
397            &self,
398            _event: &hypercall_types::EngineMessage,
399        ) -> Result<Vec<crate::portfolio::PortfolioChange>, crate::portfolio::PortfolioError>
400        {
401            Ok(vec![])
402        }
403
404        async fn apply_hypercore_position_update(
405            &self,
406            _update: &crate::portfolio::HypercorePositionUpdate,
407        ) {
408        }
409
410        async fn set_hypercore_position(
411            &self,
412            _update: &crate::portfolio::HypercorePositionUpdate,
413        ) {
414        }
415
416        async fn calculate_fill_accounting(
417            &self,
418            fill: &hypercall_types::Fill,
419        ) -> Result<hypercall_types::FillAccounting, crate::portfolio::PortfolioError> {
420            Ok(hypercall_types::FillAccounting::zero(fill.trade_id))
421        }
422
423        async fn remove_expired_position(&self, _wallet: &WalletAddress, _symbol: &str) {
424            // Mock: no-op
425        }
426
427        async fn apply_fill_to_memory(
428            &self,
429            _wallet: &WalletAddress,
430            _symbol: &str,
431            _side: &hypercall_types::Side,
432            _price: Decimal,
433            _quantity: Decimal,
434        ) {
435        }
436
437        fn as_any(&self) -> &dyn std::any::Any {
438            self
439        }
440    }
441
442    // Mock SpotPriceSource
443    struct MockSpotPriceSource {
444        prices: HashMap<String, f64>,
445    }
446
447    #[async_trait]
448    impl SpotPriceSource for MockSpotPriceSource {
449        async fn get_spot_price(&self, underlying: &str) -> Option<f64> {
450            self.prices.get(underlying).copied()
451        }
452    }
453
454    #[tokio::test]
455    async fn test_build_empty_account() {
456        let wallet = test_wallet(1);
457        let ledger = Arc::new(InMemoryLedger::new());
458        let portfolio = Arc::new(MockPortfolioService {
459            balances: RwLock::new(HashMap::new()),
460        });
461        let spot_source = Arc::new(MockSpotPriceSource {
462            prices: HashMap::new(),
463        });
464
465        let builder = StandardAccountBuilder::new(ledger.clone(), portfolio, spot_source);
466
467        let account = builder.build(&wallet).await.unwrap();
468
469        assert_eq!(account.wallet, wallet.as_hex().to_lowercase());
470        assert_eq!(account.usdc_balance, dec!(0));
471        assert!(account.option_positions.is_empty());
472        assert!(account.perp_positions.is_empty());
473    }
474
475    #[tokio::test]
476    async fn test_build_with_balance() {
477        let wallet = test_wallet(1);
478        let ledger = Arc::new(InMemoryLedger::new());
479        ledger.set_balance(&wallet, dec!(5000)).await.unwrap();
480
481        let portfolio = Arc::new(MockPortfolioService {
482            balances: RwLock::new(HashMap::new()),
483        });
484        let spot_source = Arc::new(MockSpotPriceSource {
485            prices: HashMap::new(),
486        });
487
488        let builder = StandardAccountBuilder::new(ledger, portfolio, spot_source);
489
490        let account = builder.build(&wallet).await.unwrap();
491
492        assert_eq!(account.usdc_balance, dec!(5000));
493    }
494
495    #[tokio::test]
496    async fn test_build_with_option_position() {
497        let wallet = test_wallet(1);
498        let ledger = Arc::new(InMemoryLedger::new());
499        ledger.set_balance(&wallet, dec!(5000)).await.unwrap();
500
501        let mut positions = HashMap::new();
502        positions.insert(
503            "ETH-20251231-4000-C".to_string(),
504            PositionData {
505                symbol: "ETH-20251231-4000-C".to_string(),
506                amount: dec!(10),
507                entry_price: dec!(200),
508                margin_posted: dec!(0),
509                realized_pnl: dec!(0),
510                unrealized_pnl: dec!(500),
511            },
512        );
513
514        let balance = PortfolioBalance {
515            positions,
516            total_margin_used: dec!(0),
517        };
518
519        let mut balances = HashMap::new();
520        balances.insert(wallet.as_hex().to_lowercase(), balance);
521
522        let portfolio = Arc::new(MockPortfolioService {
523            balances: RwLock::new(balances),
524        });
525
526        let mut prices = HashMap::new();
527        prices.insert("ETH".to_string(), 3500.0);
528
529        let spot_source = Arc::new(MockSpotPriceSource { prices });
530
531        let builder = StandardAccountBuilder::new(ledger, portfolio, spot_source);
532
533        let account = builder.build(&wallet).await.unwrap();
534
535        // Balance = ledger (5000)
536        assert_eq!(account.usdc_balance, dec!(5000));
537        assert_eq!(account.option_positions.len(), 1);
538
539        let opt = &account.option_positions[0];
540        assert_eq!(opt.symbol, "ETH-20251231-4000-C");
541        assert_eq!(opt.underlying, "ETH");
542        assert_eq!(opt.strike, dec!(4000));
543        assert!(opt.is_call);
544        assert_eq!(opt.size, dec!(10));
545        assert_eq!(opt.spot_price, dec!(3500));
546    }
547
548    /// Verify that mark_price and equity depend on the UPNL value passed to the builder.
549    ///
550    /// When UPNL=0 (stale after restart replay), the builder falls back to intrinsic
551    /// value (`max(spot - strike, 0)` for a call) so equity isn't inflated by the
552    /// full entry premium — see the intrinsic-fallback branch at
553    /// `build()` above and PR #1304 ("fix(server): UPNL repricing, intrinsic
554    /// fallback"). When UPNL is fresh, mark = entry_price + upnl/amount and
555    /// reflects the market.
556    #[tokio::test]
557    async fn test_mark_price_and_equity_depend_on_upnl_input() {
558        let wallet = test_wallet(1);
559        let ledger = Arc::new(InMemoryLedger::new());
560        ledger.set_balance(&wallet, dec!(5000)).await.unwrap();
561
562        // Position with UPNL=0 (e.g., stale cache or just-opened position)
563        let mut positions = HashMap::new();
564        positions.insert(
565            "BTC-20260331-100000-C".to_string(),
566            PositionData {
567                symbol: "BTC-20260331-100000-C".to_string(),
568                amount: dec!(1),
569                entry_price: dec!(5000),
570                unrealized_pnl: dec!(0),
571                margin_posted: dec!(0),
572                realized_pnl: dec!(0),
573            },
574        );
575
576        let balance = PortfolioBalance {
577            positions,
578            total_margin_used: dec!(0),
579        };
580
581        let mut balances = HashMap::new();
582        balances.insert(wallet.as_hex().to_lowercase(), balance);
583
584        let portfolio = Arc::new(MockPortfolioService {
585            balances: RwLock::new(balances),
586        });
587
588        let mut prices = HashMap::new();
589        prices.insert("BTC".to_string(), 110000.0);
590        let spot_source = Arc::new(MockSpotPriceSource { prices });
591
592        let builder = StandardAccountBuilder::new(ledger.clone(), portfolio, spot_source);
593
594        let account = builder.build(&wallet).await.unwrap();
595
596        // With UPNL=0 the builder falls back to intrinsic value:
597        // max(spot - strike, 0) = max(110000 - 100000, 0) = 10000 for the call.
598        assert_eq!(
599            account.option_positions[0].mark_price,
600            dec!(10000),
601            "mark_price = intrinsic value when UPNL=0"
602        );
603
604        let service = StandardMarginService::new();
605        let result = service.compute_margin(&account);
606
607        // Equity = cash (ledger reflects the $5000 premium debit) + signed
608        // option value at the intrinsic mark (1 * 10000) = 15000.
609        assert_eq!(
610            result.equity,
611            dec!(15000),
612            "equity reflects intrinsic option mark (premium already in cash)"
613        );
614
615        // Now show what correct behavior looks like with fresh UPNL
616        let mut fresh_positions = HashMap::new();
617        fresh_positions.insert(
618            "BTC-20260331-100000-C".to_string(),
619            PositionData {
620                symbol: "BTC-20260331-100000-C".to_string(),
621                amount: dec!(1),
622                entry_price: dec!(5000),
623                unrealized_pnl: dec!(10000), // Fresh: reflects deep ITM call mark ~15000
624                margin_posted: dec!(0),
625                realized_pnl: dec!(0),
626            },
627        );
628
629        let fresh_balance = PortfolioBalance {
630            positions: fresh_positions,
631            total_margin_used: dec!(0),
632        };
633
634        let mut fresh_balances = HashMap::new();
635        fresh_balances.insert(wallet.as_hex().to_lowercase(), fresh_balance);
636
637        let fresh_portfolio = Arc::new(MockPortfolioService {
638            balances: RwLock::new(fresh_balances),
639        });
640
641        let mut fresh_prices = HashMap::new();
642        fresh_prices.insert("BTC".to_string(), 110000.0);
643        let fresh_spot_source = Arc::new(MockSpotPriceSource {
644            prices: fresh_prices,
645        });
646
647        let fresh_builder =
648            StandardAccountBuilder::new(ledger.clone(), fresh_portfolio, fresh_spot_source);
649
650        let fresh_account = fresh_builder.build(&wallet).await.unwrap();
651
652        // With fresh UPNL, mark_price = 5000 + 10000/1 = 15000
653        assert_eq!(fresh_account.option_positions[0].mark_price, dec!(15000));
654
655        let fresh_result = service.compute_margin(&fresh_account);
656
657        // With fresh pricing, the same premium-debited cash plus the new option
658        // mark yields higher equity.
659        assert_eq!(fresh_result.equity, dec!(20000));
660
661        // Demonstrates sensitivity: stale-UPNL fallback (intrinsic, $15k equity) vs
662        // fresh UPNL (entry+upnl/amount mark, $20k equity) = $5k difference.
663        assert_ne!(
664            result.equity,
665            fresh_result.equity,
666            "equity differs by ${} depending on UPNL freshness",
667            fresh_result.equity - result.equity
668        );
669    }
670}