1#[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#[derive(Debug)]
25pub enum StandardAccountError {
26 Ledger(LedgerError),
27 Parse(String),
28 MissingSpotPrice {
30 underlying: String,
31 },
32 UnrecognizedSymbol {
34 symbol: String,
35 },
36 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
80pub 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 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 pub async fn build(
125 &self,
126 wallet: &WalletAddress,
127 ) -> Result<StandardAccount, StandardAccountError> {
128 let usdc_balance = self.balance_provider.get_balance(wallet).await?;
130
131 let portfolio_balance = self
133 .portfolio_service
134 .get_portfolio_balance(wallet)
135 .await
136 .unwrap_or_default();
137
138 let total_balance = usdc_balance;
140
141 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 for (symbol, pos_data) in &portfolio_balance.positions {
149 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 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 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 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, entry_price: pos_data.entry_price,
206 };
207
208 account.perp_positions.push(perp_pos);
209 } else {
210 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 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 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 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 }
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 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 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 #[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 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 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 assert_eq!(
610 result.equity,
611 dec!(15000),
612 "equity reflects intrinsic option mark (premium already in cash)"
613 );
614
615 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), 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 assert_eq!(fresh_account.option_positions[0].mark_price, dec!(15000));
654
655 let fresh_result = service.compute_margin(&fresh_account);
656
657 assert_eq!(fresh_result.equity, dec!(20000));
660
661 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}