Skip to main content

hypercall/rsm/portfolio_margin/
risk_account_builder.rs

1//! Risk Account Builder - Assembles Portfolio Margin inputs.
2//!
3//! This service provides a unified way to build PM snapshot inputs by combining:
4//! - **BalanceProvider**: Engine-owned runtime balances
5//! - **PortfolioService**: Executed positions with cost basis
6//! - **OpenOrdersSource**: Resting orders for PM calculations
7//!
8//! Both UnifiedEngine (for order admission) and API handlers (for /risk/grid)
9//! use this builder, keeping UnifiedEngine out of the API hot path.
10
11use super::account_builder::{expiry_to_years, BuildAccountError};
12use crate::portfolio::{PortfolioBalance, PortfolioService};
13use crate::rsm::ledger::{BalanceProvider, Ledger, LedgerBalanceProvider, LedgerError};
14use crate::shared::order_types::ParsedSymbol;
15use crate::types::Account;
16use async_trait::async_trait;
17use futures::future::join_all;
18use hypercall_margin::{
19    PortfolioMarginMarketState, PortfolioMarginOptionExposure, PortfolioMarginOptionKey,
20    PortfolioMarginPerpExposure, PortfolioMarginSnapshot, PortfolioMarginUnderlyingMarketState,
21    PortfolioMarginUnderlyingSnapshot, SnapshotComponentKind,
22};
23use hypercall_types::api_models::Order as ApiOrder;
24use hypercall_types::WalletAddress;
25use rust_decimal::prelude::ToPrimitive;
26use rust_decimal::Decimal;
27use std::collections::{BTreeSet, HashMap};
28use std::sync::Arc;
29use tracing::debug;
30
31/// Error type for risk account building.
32#[derive(Debug)]
33pub enum RiskError {
34    Ledger(LedgerError),
35    Build(BuildAccountError),
36    OpenOrders(String),
37}
38
39impl std::fmt::Display for RiskError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            RiskError::Ledger(e) => write!(f, "ledger error: {:?}", e),
43            RiskError::Build(e) => write!(f, "account build error: {}", e),
44            RiskError::OpenOrders(e) => write!(f, "open orders error: {}", e),
45        }
46    }
47}
48
49impl std::error::Error for RiskError {}
50
51impl From<LedgerError> for RiskError {
52    fn from(e: LedgerError) -> Self {
53        RiskError::Ledger(e)
54    }
55}
56
57impl From<BuildAccountError> for RiskError {
58    fn from(e: BuildAccountError) -> Self {
59        RiskError::Build(e)
60    }
61}
62
63/// Source for open orders. Implemented by SnapshotOpenOrdersSource.
64#[async_trait]
65pub trait OpenOrdersSource: Send + Sync {
66    async fn get_open_orders(&self, wallet: &WalletAddress) -> Vec<ApiOrder>;
67}
68
69/// Source for spot prices. Implemented by GreeksCache.
70#[async_trait]
71pub trait SpotPriceSource: Send + Sync {
72    async fn get_spot_price(&self, underlying: &str) -> Option<f64>;
73}
74
75/// RiskAccountBuilder assembles PM inputs from balances, positions, and open orders.
76///
77/// This is the single source of truth for PM snapshot assembly.
78/// Both UnifiedEngine and API handlers use this service.
79pub struct RiskAccountBuilder {
80    balance_provider: Arc<dyn BalanceProvider + Send + Sync>,
81    portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
82    open_orders_source: Arc<dyn OpenOrdersSource + Send + Sync>,
83    spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
84}
85
86#[derive(Clone, Copy)]
87enum SnapshotMode {
88    ExecutedOnly,
89    IncludeOpenOrders,
90}
91
92impl RiskAccountBuilder {
93    pub fn new(
94        ledger: Arc<dyn Ledger + Send + Sync>,
95        portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
96        open_orders_source: Arc<dyn OpenOrdersSource + Send + Sync>,
97        spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
98    ) -> Self {
99        Self::new_with_balance_provider(
100            Arc::new(LedgerBalanceProvider::new(ledger)),
101            portfolio_service,
102            open_orders_source,
103            spot_price_source,
104        )
105    }
106
107    pub fn new_with_balance_provider(
108        balance_provider: Arc<dyn BalanceProvider + Send + Sync>,
109        portfolio_service: Arc<dyn PortfolioService + Send + Sync>,
110        open_orders_source: Arc<dyn OpenOrdersSource + Send + Sync>,
111        spot_price_source: Arc<dyn SpotPriceSource + Send + Sync>,
112    ) -> Self {
113        Self {
114            balance_provider,
115            portfolio_service,
116            open_orders_source,
117            spot_price_source,
118        }
119    }
120
121    pub async fn build_snapshot(
122        &self,
123        wallet: &WalletAddress,
124    ) -> Result<PortfolioMarginSnapshot, RiskError> {
125        self.build_snapshot_with_mode(wallet, SnapshotMode::IncludeOpenOrders)
126            .await
127    }
128
129    async fn build_snapshot_with_mode(
130        &self,
131        wallet: &WalletAddress,
132        mode: SnapshotMode,
133    ) -> Result<PortfolioMarginSnapshot, RiskError> {
134        let portfolio_balance = self
135            .portfolio_service
136            .get_portfolio_balance(wallet)
137            .await
138            .unwrap_or_default();
139        let open_orders = match mode {
140            SnapshotMode::ExecutedOnly => Vec::new(),
141            SnapshotMode::IncludeOpenOrders => {
142                self.open_orders_source.get_open_orders(wallet).await
143            }
144        };
145        let cash_balance = self.balance_provider.get_balance(wallet).await?;
146        let spot_prices = self
147            .build_spot_prices(&portfolio_balance, &open_orders)
148            .await?;
149        let underlyings = self.build_underlying_snapshots(
150            wallet,
151            &portfolio_balance,
152            &open_orders,
153            &spot_prices,
154        )?;
155
156        let snapshot = PortfolioMarginSnapshot {
157            wallet: *wallet,
158            cash_balance,
159            underlyings,
160        };
161
162        debug!(
163            "RiskAccountBuilder: built PM snapshot for {}: cash={} underlyings={}",
164            wallet,
165            cash_balance,
166            snapshot.underlyings.len()
167        );
168
169        Ok(snapshot)
170    }
171
172    pub fn resolve_market_state(
173        &self,
174        snapshot: &PortfolioMarginSnapshot,
175        config: &crate::types::Config,
176    ) -> Result<PortfolioMarginMarketState, RiskError> {
177        let underlyings = snapshot
178            .underlyings
179            .iter()
180            .map(|underlying| {
181                let spot_price = underlying.spot_price.to_f64().ok_or_else(|| {
182                    RiskError::OpenOrders(format!(
183                        "spot price for {} in wallet {} is not representable as f64",
184                        underlying.underlying, snapshot.wallet
185                    ))
186                })?;
187                Ok((
188                    underlying.underlying.clone(),
189                    PortfolioMarginUnderlyingMarketState {
190                        spot_price,
191                        option_inputs: HashMap::new(),
192                        funding: None,
193                    },
194                ))
195            })
196            .collect::<Result<HashMap<_, _>, RiskError>>()?;
197
198        Ok(PortfolioMarginMarketState {
199            config: config.portfolio_margin_config(),
200            underlyings,
201        })
202    }
203
204    pub async fn build_executed_account_for_risk(
205        &self,
206        wallet: &WalletAddress,
207    ) -> Result<Account, RiskError> {
208        let snapshot = self
209            .build_snapshot_with_mode(wallet, SnapshotMode::ExecutedOnly)
210            .await?;
211        Ok(snapshot.to_legacy_account())
212    }
213
214    pub async fn get_spot_price(&self, underlying: &str) -> Result<f64, RiskError> {
215        self.spot_price_source
216            .get_spot_price(underlying)
217            .await
218            .ok_or_else(|| {
219                RiskError::Build(BuildAccountError::MissingSpotPrice {
220                    underlying: underlying.to_string(),
221                })
222            })
223    }
224
225    pub async fn compute_im_breakdown(
226        &self,
227        wallet: &WalletAddress,
228        margin_service: &crate::rsm::margin_service::SpanMarginService,
229    ) -> Result<(f64, f64, f64), RiskError> {
230        let snapshot = self.build_snapshot(wallet).await?;
231        let position_snapshot = snapshot.without_open_orders();
232        let details_positions =
233            self.compute_snapshot_margin(wallet, &position_snapshot, margin_service)?;
234
235        let position_im = details_positions
236            .initial_margin_required
237            .to_f64()
238            .ok_or_else(|| {
239                RiskError::OpenOrders(format!(
240                    "position initial margin for {} is not representable as f64",
241                    wallet
242                ))
243            })?;
244        let position_mm = details_positions
245            .maintenance_margin_required
246            .to_f64()
247            .ok_or_else(|| {
248                RiskError::OpenOrders(format!(
249                    "position maintenance margin for {} is not representable as f64",
250                    wallet
251                ))
252            })?;
253
254        let details_all = self.compute_snapshot_margin(wallet, &snapshot, margin_service)?;
255
256        let total_im = details_all
257            .initial_margin_required
258            .to_f64()
259            .ok_or_else(|| {
260                RiskError::OpenOrders(format!(
261                    "total initial margin for {} is not representable as f64",
262                    wallet
263                ))
264            })?;
265
266        let open_orders_im = (total_im - position_im).max(0.0);
267
268        debug!(
269            "RiskAccountBuilder: IM breakdown for {}: position_im={:.2}, open_orders_im={:.2}, position_mm={:.2}",
270            wallet, position_im, open_orders_im, position_mm
271        );
272
273        Ok((position_im, open_orders_im, position_mm))
274    }
275
276    fn compute_snapshot_margin(
277        &self,
278        wallet: &WalletAddress,
279        snapshot: &PortfolioMarginSnapshot,
280        margin_service: &crate::rsm::margin_service::SpanMarginService,
281    ) -> Result<crate::types::MarginDetails, RiskError> {
282        let market_state = self.resolve_market_state(snapshot, margin_service.config())?;
283        margin_service
284            .compute_margin_from_snapshot(snapshot, market_state)
285            .map_err(|e| RiskError::OpenOrders(format!("PM margin failed for {}: {}", wallet, e)))
286    }
287
288    /// Build spot prices map for a portfolio balance.
289    ///
290    /// Returns an error if any underlying with positions is missing a spot price.
291    /// This prevents silent degradation to equity=0 when oracles are cold.
292    async fn build_spot_prices(
293        &self,
294        balance: &PortfolioBalance,
295        open_orders: &[ApiOrder],
296    ) -> Result<HashMap<String, f64>, RiskError> {
297        let mut underlyings = BTreeSet::new();
298
299        for symbol in balance.positions.keys() {
300            if let Some(underlying) = executed_perp_underlying(symbol) {
301                underlyings.insert(underlying.to_string());
302                continue;
303            }
304
305            let parsed = parse_executed_option_symbol(symbol)?;
306            underlyings.insert(parsed.underlying);
307        }
308
309        for order in open_orders {
310            if let Some(underlying) = open_order_perp_underlying(&order.symbol) {
311                underlyings.insert(underlying.to_string());
312                continue;
313            }
314
315            let parsed = parse_open_order_option_symbol(&order.symbol)?;
316            underlyings.insert(parsed.underlying);
317        }
318
319        let fetches = underlyings.into_iter().map(|underlying| {
320            let spot_price_source = self.spot_price_source.clone();
321            async move {
322                let price = spot_price_source
323                    .get_spot_price(&underlying)
324                    .await
325                    .ok_or_else(|| {
326                        RiskError::Build(BuildAccountError::MissingSpotPrice {
327                            underlying: underlying.clone(),
328                        })
329                    })?;
330                Ok::<(String, f64), RiskError>((underlying, price))
331            }
332        });
333
334        let mut spot_prices = HashMap::new();
335        for result in join_all(fetches).await {
336            let (underlying, price) = result?;
337            spot_prices.insert(underlying, price);
338        }
339        Ok(spot_prices)
340    }
341
342    fn build_underlying_snapshots(
343        &self,
344        _wallet: &WalletAddress,
345        portfolio_balance: &PortfolioBalance,
346        open_orders: &[ApiOrder],
347        spot_prices: &HashMap<String, f64>,
348    ) -> Result<Vec<PortfolioMarginUnderlyingSnapshot>, RiskError> {
349        let mut underlyings: HashMap<String, PortfolioMarginUnderlyingSnapshot> = HashMap::new();
350
351        for (symbol, position_data) in &portfolio_balance.positions {
352            if let Some(underlying) = executed_perp_underlying(symbol) {
353                let entry = underlyings
354                    .entry(underlying.to_string())
355                    .or_insert_with(|| new_underlying_snapshot(underlying, spot_prices));
356                entry.executed_perps.push(PortfolioMarginPerpExposure {
357                    underlying: underlying.to_string(),
358                    quantity: position_data.amount,
359                    entry_price: Some(position_data.entry_price),
360                    unrealized_pnl: position_data.unrealized_pnl,
361                });
362                continue;
363            }
364
365            let parsed = parse_executed_option_symbol(symbol)?;
366            let entry = underlyings
367                .entry(parsed.underlying.clone())
368                .or_insert_with(|| new_underlying_snapshot(&parsed.underlying, spot_prices));
369            entry.executed_options.push(PortfolioMarginOptionExposure {
370                key: PortfolioMarginOptionKey {
371                    underlying: parsed.underlying.clone(),
372                    option_type: parsed.option_type.clone(),
373                    strike: parsed.strike,
374                    expiry_ts: validated_expiry_ts(&parsed.underlying, symbol, parsed.expiry)?,
375                },
376                expiry_years: decimal_from_f64(
377                    expiry_to_years(&parsed.underlying, parsed.expiry),
378                    symbol,
379                )?,
380                quantity: position_data.amount,
381                entry_price: position_data.entry_price,
382                source: SnapshotComponentKind::ExecutedPositions,
383            });
384        }
385
386        for order in open_orders {
387            let remaining_size = order.size - order.filled_size.unwrap_or(Decimal::ZERO);
388            if remaining_size <= Decimal::ZERO {
389                continue;
390            }
391            let quantity = match order.side.as_str() {
392                "Buy" => remaining_size,
393                "Sell" => -remaining_size,
394                other => {
395                    return Err(RiskError::OpenOrders(format!(
396                        "unknown order side '{}' for order {}",
397                        other, order.order_id
398                    )));
399                }
400            };
401            if let Some(underlying) = open_order_perp_underlying(&order.symbol) {
402                let entry = underlyings
403                    .entry(underlying.to_string())
404                    .or_insert_with(|| new_underlying_snapshot(underlying, spot_prices));
405                entry
406                    .hypothetical_open_order_perps
407                    .push(PortfolioMarginPerpExposure {
408                        underlying: underlying.to_string(),
409                        quantity,
410                        entry_price: None,
411                        unrealized_pnl: Decimal::ZERO,
412                    });
413                continue;
414            }
415
416            let parsed = parse_open_order_option_symbol(&order.symbol)?;
417            let entry = underlyings
418                .entry(parsed.underlying.clone())
419                .or_insert_with(|| new_underlying_snapshot(&parsed.underlying, spot_prices));
420            entry
421                .hypothetical_open_order_options
422                .push(PortfolioMarginOptionExposure {
423                    key: PortfolioMarginOptionKey {
424                        underlying: parsed.underlying.clone(),
425                        option_type: parsed.option_type.clone(),
426                        strike: parsed.strike,
427                        expiry_ts: validated_expiry_ts(
428                            &parsed.underlying,
429                            &order.symbol,
430                            parsed.expiry,
431                        )?,
432                    },
433                    expiry_years: decimal_from_f64(
434                        expiry_to_years(&parsed.underlying, parsed.expiry),
435                        &order.symbol,
436                    )?,
437                    quantity,
438                    entry_price: order.price,
439                    source: SnapshotComponentKind::OpenOrders,
440                });
441        }
442
443        let mut values = underlyings.into_values().collect::<Vec<_>>();
444        values.sort_by(|left, right| left.underlying.cmp(&right.underlying));
445        Ok(values)
446    }
447}
448
449fn new_underlying_snapshot(
450    underlying: &str,
451    spot_prices: &HashMap<String, f64>,
452) -> PortfolioMarginUnderlyingSnapshot {
453    let spot_price = spot_prices.get(underlying).copied().unwrap_or_else(|| {
454        panic!(
455            "missing spot price for underlying {} after prior validation",
456            underlying
457        )
458    });
459    PortfolioMarginUnderlyingSnapshot {
460        underlying: underlying.to_string(),
461        spot_price: Decimal::from_f64_retain(spot_price)
462            .unwrap_or_else(|| panic!("spot price {} for {} is invalid", spot_price, underlying)),
463        executed_options: Vec::new(),
464        hypothetical_open_order_options: Vec::new(),
465        executed_perps: Vec::<PortfolioMarginPerpExposure>::new(),
466        hypothetical_open_order_perps: Vec::<PortfolioMarginPerpExposure>::new(),
467    }
468}
469
470fn validated_expiry_ts(underlying: &str, symbol: &str, expiry: u64) -> Result<i64, RiskError> {
471    let expiry_ts = hypercall_types::expiry_date_to_timestamp(underlying, expiry);
472    if expiry_ts <= 0 {
473        return Err(RiskError::Build(BuildAccountError::InvalidExpiry {
474            symbol: symbol.to_string(),
475            expiry,
476        }));
477    }
478    Ok(expiry_ts)
479}
480
481fn decimal_from_f64(value: f64, symbol: &str) -> Result<Decimal, RiskError> {
482    Decimal::from_f64_retain(value).ok_or_else(|| {
483        RiskError::OpenOrders(format!(
484            "non-representable decimal value {} while building PM exposure for {}",
485            value, symbol
486        ))
487    })
488}
489
490fn parse_executed_option_symbol(symbol: &str) -> Result<ParsedSymbol, RiskError> {
491    ParsedSymbol::from_symbol(symbol).map_err(|reason| {
492        RiskError::Build(BuildAccountError::UnparseableSymbol {
493            symbol: symbol.to_string(),
494            reason,
495        })
496    })
497}
498
499fn parse_open_order_option_symbol(symbol: &str) -> Result<ParsedSymbol, RiskError> {
500    ParsedSymbol::from_symbol(symbol).map_err(|reason| {
501        RiskError::OpenOrders(format!(
502            "failed to parse open order symbol {}: {}",
503            symbol, reason
504        ))
505    })
506}
507
508use crate::shared::order_types::perp_underlying;
509
510fn executed_perp_underlying(symbol: &str) -> Option<&str> {
511    perp_underlying(symbol)
512}
513
514fn open_order_perp_underlying(symbol: &str) -> Option<&str> {
515    perp_underlying(symbol)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::rsm::ledger::InMemoryLedger;
522    use crate::rsm::margin_service::SpanMarginService;
523    use crate::types::{Config, Scenario, ScenarioType};
524    use hypercall_engine::fee::FeeConfig;
525    use hypercall_types::wallet_address::test_wallet;
526    use rust_decimal_macros::dec;
527
528    /// Mock PortfolioService for tests
529    struct MockPortfolioService {
530        balances: tokio::sync::RwLock<HashMap<WalletAddress, PortfolioBalance>>,
531    }
532
533    impl MockPortfolioService {
534        fn new() -> Self {
535            Self {
536                balances: tokio::sync::RwLock::new(HashMap::new()),
537            }
538        }
539
540        async fn set_balance(&self, wallet: &WalletAddress, balance: PortfolioBalance) {
541            self.balances.write().await.insert(*wallet, balance);
542        }
543    }
544
545    #[async_trait]
546    impl PortfolioService for MockPortfolioService {
547        async fn get_portfolio(
548            &self,
549            _account: &WalletAddress,
550        ) -> hypercall_types::api_models::Portfolio {
551            hypercall_types::api_models::Portfolio {
552                wallet_address: *_account,
553                positions: Vec::new(),
554                total_margin_used: dec!(0),
555                available_balance: dec!(0),
556                span_margin: None,
557                margin_mode: "standard".to_string(),
558                margin_summary: None,
559            }
560        }
561
562        async fn get_portfolio_balance(&self, wallet: &WalletAddress) -> Option<PortfolioBalance> {
563            self.balances.read().await.get(wallet).cloned()
564        }
565
566        async fn all_portfolios(&self) -> HashMap<WalletAddress, PortfolioBalance> {
567            self.balances.read().await.clone()
568        }
569
570        async fn apply_event(
571            &self,
572            _event: &hypercall_types::EngineMessage,
573        ) -> Result<Vec<crate::portfolio::PortfolioChange>, crate::portfolio::PortfolioError>
574        {
575            Ok(vec![])
576        }
577
578        async fn apply_hypercore_position_update(
579            &self,
580            _update: &crate::portfolio::HypercorePositionUpdate,
581        ) {
582        }
583
584        async fn set_hypercore_position(
585            &self,
586            _update: &crate::portfolio::HypercorePositionUpdate,
587        ) {
588        }
589
590        async fn calculate_fill_accounting(
591            &self,
592            fill: &hypercall_types::Fill,
593        ) -> Result<hypercall_types::FillAccounting, crate::portfolio::PortfolioError> {
594            Ok(hypercall_types::FillAccounting::zero(fill.trade_id))
595        }
596
597        async fn remove_expired_position(&self, _wallet: &WalletAddress, _symbol: &str) {
598            // Mock: no-op
599        }
600
601        async fn apply_fill_to_memory(
602            &self,
603            _wallet: &WalletAddress,
604            _symbol: &str,
605            _side: &hypercall_types::Side,
606            _price: Decimal,
607            _quantity: Decimal,
608        ) {
609        }
610
611        fn as_any(&self) -> &dyn std::any::Any {
612            self
613        }
614    }
615
616    /// Mock OpenOrdersSource for tests
617    struct MockOpenOrdersSource {
618        orders: tokio::sync::RwLock<HashMap<WalletAddress, Vec<ApiOrder>>>,
619    }
620
621    impl MockOpenOrdersSource {
622        fn new() -> Self {
623            Self {
624                orders: tokio::sync::RwLock::new(HashMap::new()),
625            }
626        }
627
628        async fn set_orders(&self, wallet: &WalletAddress, orders: Vec<ApiOrder>) {
629            self.orders.write().await.insert(*wallet, orders);
630        }
631    }
632
633    #[async_trait]
634    impl OpenOrdersSource for MockOpenOrdersSource {
635        async fn get_open_orders(&self, wallet: &WalletAddress) -> Vec<ApiOrder> {
636            self.orders
637                .read()
638                .await
639                .get(wallet)
640                .cloned()
641                .unwrap_or_default()
642        }
643    }
644
645    /// Mock SpotPriceSource for tests
646    struct MockSpotPriceSource {
647        prices: HashMap<String, f64>,
648    }
649
650    impl MockSpotPriceSource {
651        fn new(prices: HashMap<String, f64>) -> Self {
652            Self { prices }
653        }
654    }
655
656    #[async_trait]
657    impl SpotPriceSource for MockSpotPriceSource {
658        async fn get_spot_price(&self, underlying: &str) -> Option<f64> {
659            self.prices.get(underlying).copied()
660        }
661    }
662
663    fn test_margin_config() -> Config {
664        Config {
665            risk_free_rate: 0.05,
666            base_volatility: 0.8,
667            base_skew: 0.0,
668            base_excess_kurtosis: 0.0,
669            scenarios: vec![
670                Scenario {
671                    scenario_type: ScenarioType::SpotChange,
672                    value: 0.15,
673                },
674                Scenario {
675                    scenario_type: ScenarioType::SpotChange,
676                    value: -0.15,
677                },
678            ],
679            delta_threshold: 0.0001,
680            strike_match_tolerance: 0.01,
681            expiry_match_tolerance_years: 0.001,
682            allow_standard_margin_shorts: false,
683            fee_config: FeeConfig::default(),
684        }
685    }
686
687    #[tokio::test]
688    async fn test_build_account_with_portfolio_cash() {
689        let ledger = Arc::new(InMemoryLedger::new());
690        // Fund via ledger (as faucet now does)
691        ledger
692            .set_balance(&test_wallet(1), dec!(10000))
693            .await
694            .unwrap();
695
696        let portfolio_service = Arc::new(MockPortfolioService::new());
697
698        let open_orders = Arc::new(MockOpenOrdersSource::new());
699        let spot_prices = Arc::new(MockSpotPriceSource::new(HashMap::new()));
700
701        let builder = RiskAccountBuilder::new(ledger, portfolio_service, open_orders, spot_prices);
702
703        let account = builder
704            .build_snapshot(&test_wallet(1))
705            .await
706            .unwrap()
707            .to_legacy_account();
708
709        assert_eq!(account.cash, 10000.0);
710        assert!(account.portfolio.is_empty());
711    }
712
713    #[tokio::test]
714    async fn test_build_account_with_positions() {
715        let ledger = Arc::new(InMemoryLedger::new());
716        // Fund via ledger
717        ledger
718            .set_balance(&test_wallet(1), dec!(5000))
719            .await
720            .unwrap();
721
722        let portfolio_service = Arc::new(MockPortfolioService::new());
723        let mut positions = HashMap::new();
724        positions.insert(
725            "BTC-20251231-100000-C".to_string(),
726            crate::portfolio::PositionData {
727                symbol: "BTC-20251231-100000-C".to_string(),
728                amount: dec!(1),
729                entry_price: dec!(1000),
730                margin_posted: dec!(0),
731                realized_pnl: dec!(0),
732                unrealized_pnl: dec!(0),
733            },
734        );
735        portfolio_service
736            .set_balance(
737                &test_wallet(1),
738                PortfolioBalance {
739                    positions,
740                    total_margin_used: dec!(0),
741                },
742            )
743            .await;
744
745        let mut prices = HashMap::new();
746        prices.insert("BTC".to_string(), 100000.0);
747        let spot_prices = Arc::new(MockSpotPriceSource::new(prices));
748
749        let open_orders = Arc::new(MockOpenOrdersSource::new());
750
751        let builder = RiskAccountBuilder::new(ledger, portfolio_service, open_orders, spot_prices);
752
753        let account = builder
754            .build_snapshot(&test_wallet(1))
755            .await
756            .unwrap()
757            .to_legacy_account();
758
759        assert_eq!(account.cash, 5000.0);
760        assert_eq!(account.portfolio.len(), 1);
761        assert!(account.portfolio.contains_key("BTC"));
762        assert_eq!(account.portfolio["BTC"].options.len(), 1);
763    }
764
765    #[tokio::test]
766    async fn test_build_account_with_open_orders() {
767        let ledger = Arc::new(InMemoryLedger::new());
768        // Fund via ledger
769        ledger
770            .set_balance(&test_wallet(1), dec!(5000))
771            .await
772            .unwrap();
773
774        let portfolio_service = Arc::new(MockPortfolioService::new());
775
776        let open_orders = Arc::new(MockOpenOrdersSource::new());
777        open_orders
778            .set_orders(
779                &test_wallet(1),
780                vec![ApiOrder {
781                    order_id: 1,
782                    wallet_address: test_wallet(1),
783                    symbol: "BTC-20251231-100000-C".to_string(),
784                    side: "Buy".to_string(),
785                    price: dec!(1000),
786                    size: dec!(100000000), // 1.0 in contract units
787                    tif: "gtc".to_string(),
788                    status: Some("open".to_string()),
789                    created_at: 0,
790                    updated_at: None,
791                    filled_size: Some(dec!(0)),
792                    mmp_enabled: false,
793                }],
794            )
795            .await;
796
797        let mut prices = HashMap::new();
798        prices.insert("BTC".to_string(), 100000.0);
799        let spot_prices = Arc::new(MockSpotPriceSource::new(prices));
800
801        let builder = RiskAccountBuilder::new(ledger, portfolio_service, open_orders, spot_prices);
802
803        let account = builder
804            .build_snapshot(&test_wallet(1))
805            .await
806            .unwrap()
807            .to_legacy_account();
808        assert_eq!(account.portfolio.len(), 1);
809        assert!(account.portfolio.contains_key("BTC"));
810        assert_eq!(account.portfolio["BTC"].options.len(), 1);
811    }
812
813    #[tokio::test]
814    async fn test_build_executed_account_excludes_open_orders() {
815        let ledger = Arc::new(InMemoryLedger::new());
816        ledger
817            .set_balance(&test_wallet(1), dec!(5000))
818            .await
819            .unwrap();
820
821        let portfolio_service = Arc::new(MockPortfolioService::new());
822
823        let open_orders = Arc::new(MockOpenOrdersSource::new());
824        open_orders
825            .set_orders(
826                &test_wallet(1),
827                vec![ApiOrder {
828                    order_id: 1,
829                    wallet_address: test_wallet(1),
830                    symbol: "BTC-20251231-100000-C".to_string(),
831                    side: "Buy".to_string(),
832                    price: dec!(1000),
833                    size: dec!(100000000),
834                    tif: "gtc".to_string(),
835                    status: Some("open".to_string()),
836                    created_at: 0,
837                    updated_at: None,
838                    filled_size: Some(dec!(0)),
839                    mmp_enabled: false,
840                }],
841            )
842            .await;
843
844        let mut prices = HashMap::new();
845        prices.insert("BTC".to_string(), 100000.0);
846        let spot_prices = Arc::new(MockSpotPriceSource::new(prices));
847
848        let builder = RiskAccountBuilder::new(ledger, portfolio_service, open_orders, spot_prices);
849
850        let account = builder
851            .build_executed_account_for_risk(&test_wallet(1))
852            .await
853            .unwrap();
854        assert!(account.portfolio.is_empty());
855    }
856
857    #[tokio::test]
858    async fn test_build_executed_account_ignores_malformed_open_orders() {
859        let ledger = Arc::new(InMemoryLedger::new());
860        ledger
861            .set_balance(&test_wallet(1), dec!(5000))
862            .await
863            .unwrap();
864
865        let portfolio_service = Arc::new(MockPortfolioService::new());
866        let open_orders = Arc::new(MockOpenOrdersSource::new());
867        open_orders
868            .set_orders(
869                &test_wallet(1),
870                vec![ApiOrder {
871                    order_id: 42,
872                    wallet_address: test_wallet(1),
873                    symbol: "BTC-INVALID".to_string(),
874                    side: "Buy".to_string(),
875                    price: dec!(1000),
876                    size: dec!(1),
877                    tif: "gtc".to_string(),
878                    status: Some("open".to_string()),
879                    created_at: 0,
880                    updated_at: None,
881                    filled_size: Some(dec!(0)),
882                    mmp_enabled: false,
883                }],
884            )
885            .await;
886
887        let builder = RiskAccountBuilder::new(
888            ledger,
889            portfolio_service,
890            open_orders,
891            Arc::new(MockSpotPriceSource::new(HashMap::new())),
892        );
893
894        let account = builder
895            .build_executed_account_for_risk(&test_wallet(1))
896            .await
897            .expect("executed-only account should ignore malformed resting orders");
898        assert!(account.portfolio.is_empty());
899    }
900
901    #[tokio::test]
902    async fn test_build_snapshot_includes_positions_and_open_orders() {
903        let ledger = Arc::new(InMemoryLedger::new());
904        ledger
905            .set_balance(&test_wallet(1), dec!(5000))
906            .await
907            .unwrap();
908
909        let portfolio_service = Arc::new(MockPortfolioService::new());
910        let mut positions = HashMap::new();
911        positions.insert(
912            "BTC-20251231-100000-C".to_string(),
913            crate::portfolio::PositionData {
914                symbol: "BTC-20251231-100000-C".to_string(),
915                amount: dec!(2),
916                entry_price: dec!(900),
917                margin_posted: dec!(0),
918                realized_pnl: dec!(0),
919                unrealized_pnl: dec!(0),
920            },
921        );
922        portfolio_service
923            .set_balance(
924                &test_wallet(1),
925                PortfolioBalance {
926                    positions,
927                    total_margin_used: dec!(0),
928                },
929            )
930            .await;
931
932        let open_orders = Arc::new(MockOpenOrdersSource::new());
933        open_orders
934            .set_orders(
935                &test_wallet(1),
936                vec![ApiOrder {
937                    order_id: 1,
938                    wallet_address: test_wallet(1),
939                    symbol: "BTC-20251231-110000-C".to_string(),
940                    side: "Sell".to_string(),
941                    price: dec!(800),
942                    size: dec!(1),
943                    tif: "gtc".to_string(),
944                    status: Some("open".to_string()),
945                    created_at: 0,
946                    updated_at: None,
947                    filled_size: Some(dec!(0)),
948                    mmp_enabled: false,
949                }],
950            )
951            .await;
952
953        let mut prices = HashMap::new();
954        prices.insert("BTC".to_string(), 100000.0);
955        let spot_prices = Arc::new(MockSpotPriceSource::new(prices));
956        let builder = RiskAccountBuilder::new(ledger, portfolio_service, open_orders, spot_prices);
957
958        let snapshot = builder.build_snapshot(&test_wallet(1)).await.unwrap();
959        assert_eq!(snapshot.cash_balance, dec!(5000));
960        assert_eq!(snapshot.underlyings.len(), 1);
961        assert_eq!(snapshot.underlyings[0].executed_options.len(), 1);
962        assert_eq!(
963            snapshot.underlyings[0]
964                .hypothetical_open_order_options
965                .len(),
966            1
967        );
968        assert!(snapshot.underlyings[0].executed_perps.is_empty());
969        assert!(snapshot.underlyings[0]
970            .hypothetical_open_order_perps
971            .is_empty());
972    }
973
974    #[tokio::test]
975    async fn test_build_snapshot_missing_spot_price_fails_closed() {
976        let ledger = Arc::new(InMemoryLedger::new());
977        ledger
978            .set_balance(&test_wallet(1), dec!(5000))
979            .await
980            .unwrap();
981        let portfolio_service = Arc::new(MockPortfolioService::new());
982        let mut positions = HashMap::new();
983        positions.insert(
984            "BTC-20251231-100000-C".to_string(),
985            crate::portfolio::PositionData {
986                symbol: "BTC-20251231-100000-C".to_string(),
987                amount: dec!(1),
988                entry_price: dec!(1000),
989                margin_posted: dec!(0),
990                realized_pnl: dec!(0),
991                unrealized_pnl: dec!(0),
992            },
993        );
994        portfolio_service
995            .set_balance(
996                &test_wallet(1),
997                PortfolioBalance {
998                    positions,
999                    total_margin_used: dec!(0),
1000                },
1001            )
1002            .await;
1003        let builder = RiskAccountBuilder::new(
1004            ledger,
1005            portfolio_service,
1006            Arc::new(MockOpenOrdersSource::new()),
1007            Arc::new(MockSpotPriceSource::new(HashMap::new())),
1008        );
1009
1010        let err = builder.build_snapshot(&test_wallet(1)).await.unwrap_err();
1011        assert!(matches!(
1012            err,
1013            RiskError::Build(BuildAccountError::MissingSpotPrice { .. })
1014        ));
1015    }
1016
1017    #[tokio::test]
1018    async fn test_build_snapshot_malformed_open_order_fails_closed() {
1019        let ledger = Arc::new(InMemoryLedger::new());
1020        ledger
1021            .set_balance(&test_wallet(1), dec!(5000))
1022            .await
1023            .unwrap();
1024        let portfolio_service = Arc::new(MockPortfolioService::new());
1025        let open_orders = Arc::new(MockOpenOrdersSource::new());
1026        open_orders
1027            .set_orders(
1028                &test_wallet(1),
1029                vec![ApiOrder {
1030                    order_id: 99,
1031                    wallet_address: test_wallet(1),
1032                    symbol: "BTC-20251231-100000-C".to_string(),
1033                    side: "InvalidSide".to_string(),
1034                    price: dec!(1000),
1035                    size: dec!(1),
1036                    tif: "gtc".to_string(),
1037                    status: Some("open".to_string()),
1038                    created_at: 0,
1039                    updated_at: None,
1040                    filled_size: Some(dec!(0)),
1041                    mmp_enabled: false,
1042                }],
1043            )
1044            .await;
1045        let mut prices = HashMap::new();
1046        prices.insert("BTC".to_string(), 100000.0);
1047        let builder = RiskAccountBuilder::new(
1048            ledger,
1049            portfolio_service,
1050            open_orders,
1051            Arc::new(MockSpotPriceSource::new(prices)),
1052        );
1053
1054        let err = builder.build_snapshot(&test_wallet(1)).await.unwrap_err();
1055        assert!(matches!(err, RiskError::OpenOrders(_)));
1056    }
1057
1058    #[tokio::test]
1059    async fn test_build_snapshot_includes_raw_hypercore_perps() {
1060        let ledger = Arc::new(InMemoryLedger::new());
1061        ledger
1062            .set_balance(&test_wallet(1), dec!(5000))
1063            .await
1064            .unwrap();
1065        let portfolio_service = Arc::new(MockPortfolioService::new());
1066        let mut positions = HashMap::new();
1067        positions.insert(
1068            "BTC".to_string(),
1069            crate::portfolio::PositionData {
1070                symbol: "BTC".to_string(),
1071                amount: dec!(2),
1072                entry_price: dec!(95000),
1073                margin_posted: dec!(0),
1074                realized_pnl: dec!(0),
1075                unrealized_pnl: dec!(1200),
1076            },
1077        );
1078        portfolio_service
1079            .set_balance(
1080                &test_wallet(1),
1081                PortfolioBalance {
1082                    positions,
1083                    total_margin_used: dec!(0),
1084                },
1085            )
1086            .await;
1087
1088        let mut prices = HashMap::new();
1089        prices.insert("BTC".to_string(), 100000.0);
1090        let builder = RiskAccountBuilder::new(
1091            ledger,
1092            portfolio_service,
1093            Arc::new(MockOpenOrdersSource::new()),
1094            Arc::new(MockSpotPriceSource::new(prices)),
1095        );
1096
1097        let snapshot = builder.build_snapshot(&test_wallet(1)).await.unwrap();
1098        assert_eq!(snapshot.underlyings.len(), 1);
1099        assert_eq!(snapshot.underlyings[0].executed_perps.len(), 1);
1100        assert_eq!(snapshot.underlyings[0].executed_perps[0].quantity, dec!(2));
1101        assert_eq!(
1102            snapshot.underlyings[0].executed_perps[0].unrealized_pnl,
1103            dec!(1200)
1104        );
1105        assert!(snapshot.underlyings[0]
1106            .hypothetical_open_order_perps
1107            .is_empty());
1108    }
1109
1110    #[tokio::test]
1111    async fn test_build_snapshot_includes_perp_open_orders_as_hypothetical_overlay() {
1112        let ledger = Arc::new(InMemoryLedger::new());
1113        ledger
1114            .set_balance(&test_wallet(1), dec!(5000))
1115            .await
1116            .unwrap();
1117        let portfolio_service = Arc::new(MockPortfolioService::new());
1118        let open_orders = Arc::new(MockOpenOrdersSource::new());
1119        open_orders
1120            .set_orders(
1121                &test_wallet(1),
1122                vec![ApiOrder {
1123                    order_id: 7,
1124                    wallet_address: test_wallet(1),
1125                    symbol: "BTC-PERP".to_string(),
1126                    side: "Buy".to_string(),
1127                    price: dec!(100000),
1128                    size: dec!(1),
1129                    tif: "gtc".to_string(),
1130                    status: Some("open".to_string()),
1131                    created_at: 0,
1132                    updated_at: None,
1133                    filled_size: Some(dec!(0)),
1134                    mmp_enabled: false,
1135                }],
1136            )
1137            .await;
1138        let builder = RiskAccountBuilder::new(
1139            ledger,
1140            portfolio_service,
1141            open_orders,
1142            Arc::new(MockSpotPriceSource::new(HashMap::from([(
1143                "BTC".to_string(),
1144                100000.0,
1145            )]))),
1146        );
1147
1148        let snapshot = builder
1149            .build_snapshot(&test_wallet(1))
1150            .await
1151            .expect("perp open orders should be included in PM snapshot assembly");
1152        assert_eq!(snapshot.underlyings.len(), 1);
1153        assert_eq!(
1154            snapshot.underlyings[0].hypothetical_open_order_perps.len(),
1155            1
1156        );
1157        assert_eq!(
1158            snapshot.underlyings[0].hypothetical_open_order_perps[0].quantity,
1159            dec!(1)
1160        );
1161        assert_eq!(
1162            snapshot
1163                .to_legacy_account()
1164                .portfolio
1165                .get("BTC")
1166                .expect("underlying should exist")
1167                .delta,
1168            dec!(1)
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn test_build_snapshot_treats_raw_perp_open_orders_as_hypothetical_overlay() {
1174        let ledger = Arc::new(InMemoryLedger::new());
1175        ledger
1176            .set_balance(&test_wallet(1), dec!(5000))
1177            .await
1178            .unwrap();
1179        let portfolio_service = Arc::new(MockPortfolioService::new());
1180        let open_orders = Arc::new(MockOpenOrdersSource::new());
1181        open_orders
1182            .set_orders(
1183                &test_wallet(1),
1184                vec![ApiOrder {
1185                    order_id: 8,
1186                    wallet_address: test_wallet(1),
1187                    symbol: "BTC".to_string(),
1188                    side: "Sell".to_string(),
1189                    price: dec!(100000),
1190                    size: dec!(2),
1191                    tif: "gtc".to_string(),
1192                    status: Some("open".to_string()),
1193                    created_at: 0,
1194                    updated_at: None,
1195                    filled_size: Some(dec!(0)),
1196                    mmp_enabled: false,
1197                }],
1198            )
1199            .await;
1200        let builder = RiskAccountBuilder::new(
1201            ledger,
1202            portfolio_service,
1203            open_orders,
1204            Arc::new(MockSpotPriceSource::new(HashMap::from([(
1205                "BTC".to_string(),
1206                100000.0,
1207            )]))),
1208        );
1209
1210        let snapshot = builder
1211            .build_snapshot(&test_wallet(1))
1212            .await
1213            .expect("raw-symbol perp open orders should be treated as perps");
1214        assert_eq!(snapshot.underlyings.len(), 1);
1215        assert_eq!(
1216            snapshot.underlyings[0].hypothetical_open_order_perps.len(),
1217            1
1218        );
1219        assert_eq!(
1220            snapshot.underlyings[0].hypothetical_open_order_perps[0].quantity,
1221            dec!(-2)
1222        );
1223    }
1224
1225    #[tokio::test]
1226    async fn test_compute_im_breakdown_uses_snapshot_perp_entry_price() {
1227        let wallet = test_wallet(11);
1228        let ledger = Arc::new(InMemoryLedger::new());
1229        ledger.set_balance(&wallet, dec!(1000)).await.unwrap();
1230
1231        let portfolio_service = Arc::new(MockPortfolioService::new());
1232        let mut positions = HashMap::new();
1233        positions.insert(
1234            "BTC".to_string(),
1235            crate::portfolio::PositionData {
1236                symbol: "BTC".to_string(),
1237                amount: dec!(2),
1238                entry_price: dec!(90),
1239                margin_posted: dec!(0),
1240                realized_pnl: dec!(0),
1241                unrealized_pnl: dec!(5),
1242            },
1243        );
1244        portfolio_service
1245            .set_balance(
1246                &wallet,
1247                PortfolioBalance {
1248                    positions,
1249                    total_margin_used: dec!(0),
1250                },
1251            )
1252            .await;
1253
1254        let builder = RiskAccountBuilder::new(
1255            ledger,
1256            portfolio_service,
1257            Arc::new(MockOpenOrdersSource::new()),
1258            Arc::new(MockSpotPriceSource::new(HashMap::from([(
1259                "BTC".to_string(),
1260                100.0,
1261            )]))),
1262        );
1263        let margin_service = SpanMarginService::new_for_tests(test_margin_config());
1264
1265        let (position_im, open_orders_im, position_mm) = builder
1266            .compute_im_breakdown(&wallet, &margin_service)
1267            .await
1268            .expect("snapshot-based IM breakdown should succeed");
1269
1270        assert_eq!(position_im, 30.0);
1271        assert_eq!(open_orders_im, 0.0);
1272        assert_eq!(position_mm, 25.5);
1273    }
1274}