1use 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#[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#[async_trait]
65pub trait OpenOrdersSource: Send + Sync {
66 async fn get_open_orders(&self, wallet: &WalletAddress) -> Vec<ApiOrder>;
67}
68
69#[async_trait]
71pub trait SpotPriceSource: Send + Sync {
72 async fn get_spot_price(&self, underlying: &str) -> Option<f64>;
73}
74
75pub 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 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 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 }
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 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 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 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 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 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), 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}