1use super::MarginService;
7#[cfg(test)]
8use crate::constants::MM_TO_IM_RATIO;
9#[cfg(test)]
10use crate::rsm::black_scholes::black_scholes_with_moments;
11use crate::types::{Account, Config, MarginDetails};
12use crate::vol_oracle::{SharedVolOracle, VolLookupError, VolOracleStatus, VolProviderKind};
13use async_trait::async_trait;
14use hypercall_margin::{
15 compute_extended_risk_grid_from_snapshot, compute_risk_grid_from_snapshot,
16 empty_portfolio_margin_details, has_portfolio_positions, market_state_from_snapshot,
17 snapshot_from_account, ExtendedRiskGrid, ScenarioPnl,
18};
19use hypercall_margin::{
20 PortfolioMarginMarketState, PortfolioMarginOptionMarketState, PortfolioMarginSnapshot,
21};
22use metrics::{counter, histogram};
23use rust_decimal::prelude::ToPrimitive;
24use rust_decimal::Decimal;
25use std::sync::Arc;
26use std::time::Instant;
27use tracing::debug;
28
29struct MarginObsGuard {
33 op: &'static str,
34 start: Instant,
35}
36
37impl MarginObsGuard {
38 fn new(op: &'static str) -> Self {
39 Self {
40 op,
41 start: Instant::now(),
42 }
43 }
44}
45
46impl Drop for MarginObsGuard {
47 fn drop(&mut self) {
48 counter!("margin_span_calls_total", "op" => self.op).increment(1);
49 histogram!("margin_span_compute_seconds", "op" => self.op)
50 .record(self.start.elapsed().as_secs_f64());
51 }
52}
53
54fn current_margin_timestamp() -> i64 {
55 chrono::Utc::now().timestamp()
56}
57
58#[derive(Debug, Clone)]
59pub enum MarginError {
60 InvalidStrike {
61 underlying: String,
62 strike: Decimal,
63 },
64 NonRepresentableDecimal {
65 field: &'static str,
66 underlying: String,
67 },
68 VolLookup(VolLookupError),
69}
70
71impl std::fmt::Display for MarginError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 Self::InvalidStrike { underlying, strike } => {
75 write!(
76 f,
77 "invalid strike {strike} for underlying {underlying} in margin calculation"
78 )
79 }
80 Self::NonRepresentableDecimal { field, underlying } => {
81 write!(
82 f,
83 "non-representable decimal field {field} for underlying {underlying}"
84 )
85 }
86 Self::VolLookup(err) => write!(f, "{err}"),
87 }
88 }
89}
90
91impl std::error::Error for MarginError {}
92
93impl From<VolLookupError> for MarginError {
94 fn from(value: VolLookupError) -> Self {
95 Self::VolLookup(value)
96 }
97}
98
99impl From<hypercall_margin::MarginError> for MarginError {
100 fn from(value: hypercall_margin::MarginError) -> Self {
101 match value {
102 hypercall_margin::MarginError::InvalidStrike { underlying, strike } => {
103 Self::InvalidStrike { underlying, strike }
104 }
105 hypercall_margin::MarginError::NonRepresentableDecimal { field, underlying } => {
106 Self::NonRepresentableDecimal { field, underlying }
107 }
108 }
109 }
110}
111
112pub struct FixedRiskVolOracle {
113 fixed_vol: f64,
114}
115
116impl FixedRiskVolOracle {
117 pub fn new(fixed_vol: f64) -> Self {
118 Self { fixed_vol }
119 }
120}
121
122impl crate::vol_oracle::RiskVolOracle for FixedRiskVolOracle {
123 fn get_iv(
124 &self,
125 _underlying: &str,
126 _strike: f64,
127 _expiry_ts: i64,
128 ) -> Result<f64, VolLookupError> {
129 Ok(self.fixed_vol)
130 }
131
132 fn statuses(&self) -> Vec<VolOracleStatus> {
133 Vec::new()
134 }
135}
136
137struct MissingRiskVolOracle;
138
139impl crate::vol_oracle::RiskVolOracle for MissingRiskVolOracle {
140 fn get_iv(
141 &self,
142 underlying: &str,
143 _strike: f64,
144 _expiry_ts: i64,
145 ) -> Result<f64, VolLookupError> {
146 Err(VolLookupError::UnhealthyProvider {
147 underlying: underlying.to_string(),
148 provider: match underlying {
149 "BTC" | "ETH" => VolProviderKind::BlockScholes,
150 "US500" | "USOIL" => VolProviderKind::Polygon,
151 _ => VolProviderKind::BlockScholes,
152 },
153 reason: "risk volatility oracle is not configured".to_string(),
154 })
155 }
156
157 fn statuses(&self) -> Vec<VolOracleStatus> {
158 ["BTC", "ETH", "US500", "USOIL"]
159 .into_iter()
160 .map(|underlying| VolOracleStatus {
161 underlying: underlying.to_string(),
162 provider: match underlying {
163 "BTC" | "ETH" => VolProviderKind::BlockScholes,
164 _ => VolProviderKind::Polygon,
165 },
166 route_facing: true,
167 connected: false,
168 ready: false,
169 last_update_ts_ms: None,
170 staleness_seconds: None,
171 staleness_threshold_seconds: None,
172 surface_points: 0,
173 messages_received: 0,
174 last_error: Some("risk volatility oracle is not configured".to_string()),
175 })
176 .collect()
177 }
178}
179
180pub struct SpanMarginService {
201 config: Config,
202 vol_oracle: SharedVolOracle,
203}
204
205impl SpanMarginService {
206 pub fn new(config: Config) -> Self {
208 Self::new_fail_closed(config)
209 }
210
211 pub fn new_fail_closed(config: Config) -> Self {
213 Self::new_with_vol_oracle(config, Arc::new(MissingRiskVolOracle))
214 }
215
216 pub fn new_for_tests(config: Config) -> Self {
218 let fixed_vol = config.base_volatility;
219 Self::new_with_vol_oracle(config, Arc::new(FixedRiskVolOracle::new(fixed_vol)))
220 }
221
222 pub fn new_with_vol_oracle(config: Config, vol_oracle: SharedVolOracle) -> Self {
223 Self { config, vol_oracle }
224 }
225
226 pub fn config(&self) -> &Config {
227 &self.config
228 }
229
230 pub fn set_vol_oracle(&mut self, oracle: SharedVolOracle) {
231 self.vol_oracle = oracle;
232 }
233
234 fn snapshot_and_market_state_from_account(
235 &self,
236 account: &Account,
237 ) -> (PortfolioMarginSnapshot, PortfolioMarginMarketState) {
238 let snapshot = snapshot_from_account(account);
239 let market_state =
240 market_state_from_snapshot(&snapshot, self.config.portfolio_margin_config());
241 (snapshot, market_state)
242 }
243
244 fn populate_option_market_state(
245 &self,
246 snapshot: &PortfolioMarginSnapshot,
247 market_state: &mut PortfolioMarginMarketState,
248 ) -> Result<(), MarginError> {
249 for underlying in &snapshot.underlyings {
250 let underlying_state = market_state
251 .underlyings
252 .get_mut(&underlying.underlying)
253 .expect("market state missing underlying after prior validation");
254 for option in underlying
255 .executed_options
256 .iter()
257 .chain(underlying.hypothetical_open_order_options.iter())
258 {
259 if underlying_state.option_inputs.contains_key(&option.key) {
260 continue;
261 }
262 let strike =
263 option
264 .key
265 .strike
266 .to_f64()
267 .ok_or_else(|| MarginError::InvalidStrike {
268 underlying: underlying.underlying.clone(),
269 strike: option.key.strike,
270 })?;
271 let iv = self
272 .lookup_option_iv_from_key(&option.key.underlying, strike, option.key.expiry_ts)
273 .map_err(|e| {
274 tracing::error!(
275 underlying = %option.key.underlying,
276 strike = strike,
277 expiry_ts = option.key.expiry_ts,
278 error = %e,
279 "IV lookup failed, rejecting margin computation"
280 );
281 e
282 })?;
283 underlying_state.option_inputs.insert(
284 option.key.clone(),
285 PortfolioMarginOptionMarketState {
286 implied_volatility: iv,
287 },
288 );
289 }
290 }
291 Ok(())
292 }
293
294 pub fn compute_span(&self, accounts: &[Account]) -> Result<Vec<MarginDetails>, MarginError> {
309 self.compute_span_at(accounts, current_margin_timestamp())
310 }
311
312 pub fn compute_span_at(
313 &self,
314 accounts: &[Account],
315 now_ts: i64,
316 ) -> Result<Vec<MarginDetails>, MarginError> {
317 let _m = MarginObsGuard::new("compute_span");
318 let mut results = Vec::with_capacity(accounts.len());
319
320 for account in accounts {
321 results.push(self.compute_margin_for_account_at(account, now_ts)?);
322 }
323
324 Ok(results)
325 }
326
327 pub fn compute_risk_grid(&self, account: &Account) -> Result<Vec<ScenarioPnl>, MarginError> {
337 let _m = MarginObsGuard::new("compute_risk_grid");
338 let (snapshot, market_state) = self.snapshot_and_market_state_from_account(account);
339 self.compute_risk_grid_from_snapshot(&snapshot, market_state)
340 }
341
342 pub fn compute_extended_risk_grid(
347 &self,
348 account: &Account,
349 ) -> Result<ExtendedRiskGrid, MarginError> {
350 let _m = MarginObsGuard::new("compute_extended_risk_grid");
351 let (snapshot, market_state) = self.snapshot_and_market_state_from_account(account);
352 self.compute_extended_risk_grid_from_snapshot(&snapshot, market_state)
353 }
354
355 pub fn compute_margin_from_snapshot(
356 &self,
357 snapshot: &PortfolioMarginSnapshot,
358 market_state: PortfolioMarginMarketState,
359 ) -> Result<MarginDetails, MarginError> {
360 self.compute_margin_from_snapshot_at(snapshot, market_state, current_margin_timestamp())
361 }
362
363 pub fn compute_margin_from_snapshot_at(
364 &self,
365 snapshot: &PortfolioMarginSnapshot,
366 mut market_state: PortfolioMarginMarketState,
367 now_ts: i64,
368 ) -> Result<MarginDetails, MarginError> {
369 self.populate_option_market_state(snapshot, &mut market_state)?;
370 let margin_result =
371 hypercall_margin::compute_span_margin_at(snapshot, &market_state, now_ts)?;
372 Ok(MarginDetails {
373 account_id: margin_result.account_id,
374 scanning_risk: margin_result.scanning_risk,
375 option_floor: margin_result.option_floor,
376 gamma_overlay: margin_result.gamma_overlay,
377 net_option_value: margin_result.net_option_value,
378 equity: margin_result.equity,
379 initial_margin_required: margin_result.initial_margin_required,
380 maintenance_margin_required: margin_result.maintenance_margin_required,
381 })
382 }
383
384 pub fn compute_risk_grid_from_snapshot(
385 &self,
386 snapshot: &PortfolioMarginSnapshot,
387 mut market_state: PortfolioMarginMarketState,
388 ) -> Result<Vec<ScenarioPnl>, MarginError> {
389 self.populate_option_market_state(snapshot, &mut market_state)?;
390 Ok(compute_risk_grid_from_snapshot(snapshot, &market_state)?)
391 }
392
393 pub fn compute_extended_risk_grid_from_snapshot(
394 &self,
395 snapshot: &PortfolioMarginSnapshot,
396 mut market_state: PortfolioMarginMarketState,
397 ) -> Result<ExtendedRiskGrid, MarginError> {
398 self.populate_option_market_state(snapshot, &mut market_state)?;
399 Ok(compute_extended_risk_grid_from_snapshot(
400 snapshot,
401 &market_state,
402 )?)
403 }
404
405 fn lookup_option_iv_from_key(
408 &self,
409 underlying: &str,
410 strike: f64,
411 expiry_ts: i64,
412 ) -> Result<f64, MarginError> {
413 match self.vol_oracle.get_iv(underlying, strike, expiry_ts) {
414 Ok(iv) => Ok(iv),
415 Err(err) => {
416 counter!(
417 "ht_vol_lookup_failures_total",
418 "underlying" => underlying.to_string()
419 )
420 .increment(1);
421 Err(err.into())
422 }
423 }
424 }
425
426 fn has_positions(&self, account: &Account) -> bool {
439 has_portfolio_positions(account, self.config.delta_threshold)
440 }
441}
442
443#[async_trait]
444impl MarginService for SpanMarginService {
445 async fn compute_margin_for_account(
446 &self,
447 account: &Account,
448 ) -> Result<MarginDetails, MarginError> {
449 self.compute_margin_for_account_at(account, current_margin_timestamp())
450 }
451}
452
453impl SpanMarginService {
454 pub fn compute_margin_for_account_at(
455 &self,
456 account: &Account,
457 now_ts: i64,
458 ) -> Result<MarginDetails, MarginError> {
459 let _m = MarginObsGuard::new("compute_margin_for_account");
460 if !self.has_positions(account) {
463 return Ok(empty_portfolio_margin_details(account));
464 }
465
466 debug!(
468 "MarginService: Computing SPAN margin for account {} (PM mode)",
469 account.id
470 );
471 let (snapshot, market_state) = self.snapshot_and_market_state_from_account(account);
472 self.compute_margin_from_snapshot_at(&snapshot, market_state, now_ts)
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::types::{OptionContract, OptionType, Position, Scenario, ScenarioType};
480 use crate::vol_oracle::{RiskVolOracle, VolLookupError, VolOracleStatus, VolProviderKind};
481 use chrono::Utc;
482 use hypercall_engine::FeeConfig;
483 use hypercall_types::wallet_address::test_wallet;
484 use rust_decimal_macros::dec;
485 use std::collections::HashMap;
486 use std::sync::Arc;
487
488 const FIXED_NOW_TS: i64 = 1_700_000_000;
489 const TEST_EXPIRY_TS: i64 = 1_767_225_600;
490 const NEAR_EXPIRY_TS: i64 = FIXED_NOW_TS + 24 * 3600;
491
492 fn create_test_config() -> Config {
493 Config {
494 risk_free_rate: 0.05,
495 base_volatility: 0.8,
496 base_skew: 0.0,
497 base_excess_kurtosis: 0.0,
498 scenarios: vec![
499 Scenario {
500 scenario_type: ScenarioType::SpotChange,
501 value: 0.15,
502 },
503 Scenario {
504 scenario_type: ScenarioType::SpotChange,
505 value: -0.15,
506 },
507 ],
508 delta_threshold: 0.0001,
509 strike_match_tolerance: 0.01,
510 expiry_match_tolerance_years: 0.001,
511 allow_standard_margin_shorts: false,
512 fee_config: FeeConfig::default(),
513 }
514 }
515
516 fn create_low_shock_config() -> Config {
517 Config {
518 risk_free_rate: 0.05,
519 base_volatility: 0.8,
520 base_skew: 0.0,
521 base_excess_kurtosis: 0.0,
522 scenarios: vec![
523 Scenario {
524 scenario_type: ScenarioType::SpotChange,
525 value: 0.0001,
526 },
527 Scenario {
528 scenario_type: ScenarioType::SpotChange,
529 value: -0.0001,
530 },
531 ],
532 delta_threshold: 0.0001,
533 strike_match_tolerance: 0.01,
534 expiry_match_tolerance_years: 0.001,
535 allow_standard_margin_shorts: false,
536 fee_config: FeeConfig::default(),
537 }
538 }
539
540 fn create_high_shock_config() -> Config {
541 Config {
542 risk_free_rate: 0.05,
543 base_volatility: 0.8,
544 base_skew: 0.0,
545 base_excess_kurtosis: 0.0,
546 scenarios: vec![
547 Scenario {
548 scenario_type: ScenarioType::SpotChange,
549 value: 0.35,
550 },
551 Scenario {
552 scenario_type: ScenarioType::SpotChange,
553 value: -0.35,
554 },
555 ],
556 delta_threshold: 0.0001,
557 strike_match_tolerance: 0.01,
558 expiry_match_tolerance_years: 0.001,
559 allow_standard_margin_shorts: false,
560 fee_config: FeeConfig::default(),
561 }
562 }
563
564 fn expiry_years_from_timestamps(now_ts: i64, expiry_ts: i64) -> Decimal {
565 Decimal::from(expiry_ts - now_ts) / Decimal::from(365_i64 * 24 * 3600)
566 }
567
568 fn make_near_expiry_short_snapshot() -> PortfolioMarginSnapshot {
569 PortfolioMarginSnapshot {
570 wallet: test_wallet(180),
571 cash_balance: dec!(10000),
572 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
573 underlying: "BTC".to_string(),
574 spot_price: dec!(100000),
575 executed_options: vec![hypercall_margin::PortfolioMarginOptionExposure {
576 key: hypercall_margin::PortfolioMarginOptionKey {
577 underlying: "BTC".to_string(),
578 option_type: OptionType::Call,
579 strike: dec!(100000),
580 expiry_ts: NEAR_EXPIRY_TS,
581 },
582 expiry_years: expiry_years_from_timestamps(FIXED_NOW_TS, NEAR_EXPIRY_TS),
583 quantity: dec!(-1),
584 entry_price: dec!(5000),
585 source: hypercall_margin::SnapshotComponentKind::ExecutedPositions,
586 }],
587 hypothetical_open_order_options: Vec::new(),
588 executed_perps: Vec::new(),
589 hypothetical_open_order_perps: Vec::new(),
590 }],
591 }
592 }
593
594 fn make_near_expiry_vertical_spread_snapshot() -> PortfolioMarginSnapshot {
595 PortfolioMarginSnapshot {
596 wallet: test_wallet(182),
597 cash_balance: dec!(10000),
598 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
599 underlying: "BTC".to_string(),
600 spot_price: dec!(100000),
601 executed_options: vec![
602 hypercall_margin::PortfolioMarginOptionExposure {
603 key: hypercall_margin::PortfolioMarginOptionKey {
604 underlying: "BTC".to_string(),
605 option_type: OptionType::Call,
606 strike: dec!(100000),
607 expiry_ts: NEAR_EXPIRY_TS,
608 },
609 expiry_years: expiry_years_from_timestamps(FIXED_NOW_TS, NEAR_EXPIRY_TS),
610 quantity: dec!(-1),
611 entry_price: dec!(5000),
612 source: hypercall_margin::SnapshotComponentKind::ExecutedPositions,
613 },
614 hypercall_margin::PortfolioMarginOptionExposure {
615 key: hypercall_margin::PortfolioMarginOptionKey {
616 underlying: "BTC".to_string(),
617 option_type: OptionType::Call,
618 strike: dec!(100500),
619 expiry_ts: NEAR_EXPIRY_TS,
620 },
621 expiry_years: expiry_years_from_timestamps(FIXED_NOW_TS, NEAR_EXPIRY_TS),
622 quantity: dec!(1),
623 entry_price: dec!(4700),
624 source: hypercall_margin::SnapshotComponentKind::ExecutedPositions,
625 },
626 ],
627 hypothetical_open_order_options: Vec::new(),
628 executed_perps: Vec::new(),
629 hypothetical_open_order_perps: Vec::new(),
630 }],
631 }
632 }
633
634 struct TestRiskVolOracle {
635 iv: f64,
636 error: Option<VolLookupError>,
637 }
638
639 impl TestRiskVolOracle {
640 fn with_iv(iv: f64) -> Self {
641 Self { iv, error: None }
642 }
643
644 fn with_error(error: VolLookupError) -> Self {
645 Self {
646 iv: 0.0,
647 error: Some(error),
648 }
649 }
650 }
651
652 impl RiskVolOracle for TestRiskVolOracle {
653 fn get_iv(
654 &self,
655 _underlying: &str,
656 _strike: f64,
657 _expiry_ts: i64,
658 ) -> Result<f64, VolLookupError> {
659 match &self.error {
660 Some(error) => Err(error.clone()),
661 None => Ok(self.iv),
662 }
663 }
664
665 fn statuses(&self) -> Vec<VolOracleStatus> {
666 vec![VolOracleStatus {
667 underlying: "BTC".to_string(),
668 provider: VolProviderKind::BlockScholes,
669 route_facing: true,
670 connected: self.error.is_none(),
671 ready: self.error.is_none(),
672 last_update_ts_ms: Some(Utc::now().timestamp_millis()),
673 staleness_seconds: Some(0.0),
674 staleness_threshold_seconds: Some(120.0),
675 surface_points: 1,
676 messages_received: 1,
677 last_error: self.error.as_ref().map(ToString::to_string),
678 }]
679 }
680 }
681
682 #[test]
683 fn test_compute_span_empty_accounts() {
684 let service = SpanMarginService::new_for_tests(create_test_config());
685 let results = service
686 .compute_span(&[])
687 .expect("empty compute_span should succeed");
688 assert!(results.is_empty());
689 }
690
691 #[test]
692 fn test_compute_span_cash_only_account_returns_zero_margin_entry() {
693 let service = SpanMarginService::new_for_tests(create_test_config());
694 let account = Account {
695 id: test_wallet(99),
696 portfolio: HashMap::new(),
697 cash: 50_000.0,
698 address: None,
699 };
700
701 let results = service
702 .compute_span(&[account])
703 .expect("cash-only compute_span should succeed");
704
705 assert_eq!(results.len(), 1);
706 assert_eq!(results[0].equity, dec!(50000));
707 assert_eq!(results[0].initial_margin_required, Decimal::ZERO);
708 assert_eq!(results[0].maintenance_margin_required, Decimal::ZERO);
709 }
710
711 #[test]
712 fn test_compute_span_long_only_computes_margin() {
713 let service = SpanMarginService::new_for_tests(create_test_config());
714
715 let mut portfolio = HashMap::new();
716 portfolio.insert(
717 "BTC".to_string(),
718 Position {
719 spot: dec!(50000),
720 delta: dec!(0),
721 perp_unrealized_pnl: Decimal::ZERO,
722 options: vec![OptionContract {
723 option_type: OptionType::Call,
724 strike: dec!(50000),
725 expiry_ts: TEST_EXPIRY_TS,
726 expiry: dec!(0.25),
727 quantity: dec!(10), entry_price: dec!(0), }],
730 },
731 );
732
733 let account = Account {
734 id: test_wallet(100),
735 portfolio,
736 cash: 10000.0,
737 address: None,
738 };
739
740 let results = service
742 .compute_span(&[account])
743 .expect("long-only compute_span should succeed");
744 assert_eq!(results.len(), 1);
745 assert!(results[0].net_option_value > Decimal::ZERO);
747 assert!(results[0].initial_margin_required >= Decimal::ZERO);
748 }
749
750 #[test]
751 fn test_snapshot_pipeline_matches_legacy_account_margin() {
752 let service = SpanMarginService::new_for_tests(create_test_config());
753
754 let mut portfolio = HashMap::new();
755 portfolio.insert(
756 "BTC".to_string(),
757 Position {
758 spot: dec!(50000),
759 delta: dec!(0),
760 perp_unrealized_pnl: Decimal::ZERO,
761 options: vec![OptionContract {
762 option_type: OptionType::Call,
763 strike: dec!(50000),
764 expiry_ts: TEST_EXPIRY_TS,
765 expiry: dec!(0.25),
766 quantity: dec!(2),
767 entry_price: dec!(1000),
768 }],
769 },
770 );
771
772 let account = Account {
773 id: test_wallet(101),
774 portfolio,
775 cash: 10000.0,
776 address: None,
777 };
778
779 let legacy = futures::executor::block_on(service.compute_margin_for_account(&account))
780 .expect("legacy account margin should succeed");
781 let snapshot = snapshot_from_account(&account);
782 let market_state =
783 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
784 let pipeline = service
785 .compute_margin_from_snapshot(&snapshot, market_state)
786 .expect("snapshot pipeline should succeed");
787
788 assert_eq!(
789 legacy.initial_margin_required,
790 pipeline.initial_margin_required
791 );
792 assert_eq!(
793 legacy.maintenance_margin_required,
794 pipeline.maintenance_margin_required
795 );
796 assert_eq!(legacy.equity, pipeline.equity);
797 }
798
799 #[test]
800 fn test_snapshot_pipeline_risk_grid_matches_legacy_count() {
801 let service = SpanMarginService::new_for_tests(create_test_config());
802
803 let mut portfolio = HashMap::new();
804 portfolio.insert(
805 "BTC".to_string(),
806 Position {
807 spot: dec!(50000),
808 delta: dec!(0),
809 perp_unrealized_pnl: Decimal::ZERO,
810 options: vec![OptionContract {
811 option_type: OptionType::Put,
812 strike: dec!(45000),
813 expiry_ts: TEST_EXPIRY_TS,
814 expiry: dec!(0.25),
815 quantity: dec!(1),
816 entry_price: dec!(800),
817 }],
818 },
819 );
820
821 let account = Account {
822 id: test_wallet(102),
823 portfolio,
824 cash: 5000.0,
825 address: None,
826 };
827
828 let legacy = service
829 .compute_risk_grid(&account)
830 .expect("legacy risk grid should succeed");
831 let snapshot = snapshot_from_account(&account);
832 let market_state =
833 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
834 let pipeline = service
835 .compute_risk_grid_from_snapshot(&snapshot, market_state)
836 .expect("snapshot risk grid should succeed");
837
838 assert_eq!(legacy.len(), pipeline.len());
839 for (left, right) in legacy.iter().zip(pipeline.iter()) {
840 assert_eq!(left.scenario.id, right.scenario.id);
841 assert_eq!(left.scenario.spot_shock_pct, right.scenario.spot_shock_pct);
842 assert_eq!(left.scenario.vol_shock_pct, right.scenario.vol_shock_pct);
843 assert_eq!(left.scenario.pnl_weight, right.scenario.pnl_weight);
844 assert_eq!(left.scenario.is_tail, right.scenario.is_tail);
845 assert_eq!(left.total_pnl, right.total_pnl);
846 }
847 }
848
849 #[test]
850 fn test_snapshot_pipeline_extended_grid_includes_perp_rows() {
851 let service = SpanMarginService::new_for_tests(create_test_config());
852
853 let mut portfolio = HashMap::new();
854 portfolio.insert(
855 "BTC".to_string(),
856 Position {
857 spot: dec!(50000),
858 delta: dec!(2),
859 perp_unrealized_pnl: Decimal::ZERO,
860 options: vec![OptionContract {
861 option_type: OptionType::Call,
862 strike: dec!(50000),
863 expiry_ts: TEST_EXPIRY_TS,
864 expiry: dec!(0.25),
865 quantity: dec!(1),
866 entry_price: dec!(1000),
867 }],
868 },
869 );
870
871 let account = Account {
872 id: test_wallet(103),
873 portfolio,
874 cash: 10000.0,
875 address: None,
876 };
877
878 let legacy = service
879 .compute_extended_risk_grid(&account)
880 .expect("legacy extended risk grid should succeed");
881 let snapshot = snapshot_from_account(&account);
882 let market_state =
883 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
884 let pipeline = service
885 .compute_extended_risk_grid_from_snapshot(&snapshot, market_state)
886 .expect("snapshot extended risk grid should succeed");
887
888 assert_eq!(legacy.instruments.len(), pipeline.instruments.len());
889 assert!(
890 pipeline
891 .instruments
892 .iter()
893 .any(|row| row.symbol == "BTC-PERP"),
894 "pipeline grid should preserve perp instrument rows"
895 );
896 assert_eq!(legacy.total_pnls, pipeline.total_pnls);
897 assert_eq!(legacy.worst_scenario_index, pipeline.worst_scenario_index);
898 assert_eq!(legacy.worst_scenario_pnl, pipeline.worst_scenario_pnl);
899 }
900
901 #[test]
902 fn test_snapshot_pipeline_includes_executed_perp_upnl_in_equity() {
903 let service = SpanMarginService::new_for_tests(create_test_config());
904 let wallet = test_wallet(105);
905 let snapshot = PortfolioMarginSnapshot {
906 wallet,
907 cash_balance: dec!(1000),
908 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
909 underlying: "BTC".to_string(),
910 spot_price: dec!(100),
911 executed_options: Vec::new(),
912 hypothetical_open_order_options: Vec::new(),
913 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
914 underlying: "BTC".to_string(),
915 quantity: dec!(2),
916 entry_price: Some(dec!(90)),
917 unrealized_pnl: dec!(20),
918 }],
919 hypothetical_open_order_perps: Vec::new(),
920 }],
921 };
922 let market_state =
923 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
924
925 let details = service
926 .compute_margin_from_snapshot(&snapshot, market_state)
927 .expect("snapshot margin should succeed");
928
929 assert_eq!(details.equity, dec!(1020));
930 }
931
932 #[test]
933 fn test_snapshot_pipeline_reprices_executed_perp_upnl_from_live_mark() {
934 let service = SpanMarginService::new_for_tests(create_test_config());
935 let wallet = test_wallet(108);
936 let snapshot = PortfolioMarginSnapshot {
937 wallet,
938 cash_balance: dec!(1000),
939 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
940 underlying: "BTC".to_string(),
941 spot_price: dec!(100),
942 executed_options: Vec::new(),
943 hypothetical_open_order_options: Vec::new(),
944 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
945 underlying: "BTC".to_string(),
946 quantity: dec!(2),
947 entry_price: Some(dec!(90)),
948 unrealized_pnl: dec!(5),
949 }],
950 hypothetical_open_order_perps: Vec::new(),
951 }],
952 };
953 let market_state =
954 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
955
956 let details = service
957 .compute_margin_from_snapshot(&snapshot, market_state)
958 .expect("snapshot margin should succeed");
959
960 assert_eq!(details.equity, dec!(1020));
961 }
962
963 #[test]
964 fn test_extended_risk_grid_worst_scenario_pnl_uses_weighted_total() {
965 let service = SpanMarginService::new_for_tests(create_test_config());
966 let wallet = test_wallet(109);
967 let snapshot = PortfolioMarginSnapshot {
968 wallet,
969 cash_balance: dec!(1000),
970 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
971 underlying: "BTC".to_string(),
972 spot_price: dec!(100),
973 executed_options: Vec::new(),
974 hypothetical_open_order_options: Vec::new(),
975 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
976 underlying: "BTC".to_string(),
977 quantity: dec!(1),
978 entry_price: Some(dec!(100)),
979 unrealized_pnl: Decimal::ZERO,
980 }],
981 hypothetical_open_order_perps: Vec::new(),
982 }],
983 };
984 let extended_grid_market_state =
985 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
986 let grid = service
987 .compute_extended_risk_grid_from_snapshot(&snapshot, extended_grid_market_state)
988 .expect("extended risk grid should succeed");
989
990 let worst_scenario = &grid.scenarios[grid.worst_scenario_index];
991 assert_eq!(worst_scenario.id, "T1");
992 let raw_worst_pnl = grid.total_pnls[grid.worst_scenario_index];
993 assert!((raw_worst_pnl + 25.0).abs() < 1e-9);
994 assert!((grid.worst_scenario_pnl + 15.0).abs() < 1e-9);
995 assert!((grid.worst_scenario_pnl - raw_worst_pnl * worst_scenario.pnl_weight).abs() < 1e-9);
996
997 let margin_market_state =
998 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
999 let details = service
1000 .compute_margin_from_snapshot(&snapshot, margin_market_state)
1001 .expect("snapshot margin should succeed");
1002 assert_eq!(details.scanning_risk, dec!(15));
1003 assert!((details.scanning_risk.to_f64().unwrap() + grid.worst_scenario_pnl).abs() < 1e-9);
1004 }
1005
1006 #[test]
1007 fn test_legacy_account_perp_equity_uses_stored_upnl_without_entry_price() {
1008 let service = SpanMarginService::new_for_tests(create_test_config());
1009
1010 let mut portfolio = HashMap::new();
1011 portfolio.insert(
1012 "BTC".to_string(),
1013 Position {
1014 spot: dec!(100),
1015 delta: dec!(0),
1016 perp_unrealized_pnl: dec!(20),
1017 options: Vec::new(),
1018 },
1019 );
1020
1021 let account = Account {
1022 id: test_wallet(110),
1023 portfolio,
1024 cash: 1000.0,
1025 address: None,
1026 };
1027
1028 let details = futures::executor::block_on(service.compute_margin_for_account(&account))
1029 .expect("account margin should succeed");
1030
1031 assert_eq!(details.equity, dec!(1020));
1032 }
1033
1034 #[test]
1035 fn test_extended_risk_grid_prefers_explicit_perp_upnl() {
1036 let service = SpanMarginService::new_for_tests(create_test_config());
1037 let wallet = test_wallet(110);
1038 let snapshot = PortfolioMarginSnapshot {
1039 wallet,
1040 cash_balance: dec!(1000),
1041 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
1042 underlying: "BTC".to_string(),
1043 spot_price: dec!(100),
1044 executed_options: Vec::new(),
1045 hypothetical_open_order_options: Vec::new(),
1046 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
1047 underlying: "BTC".to_string(),
1048 quantity: dec!(2),
1049 entry_price: Some(dec!(90)),
1050 unrealized_pnl: dec!(5),
1051 }],
1052 hypothetical_open_order_perps: Vec::new(),
1053 }],
1054 };
1055 let market_state =
1056 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1057
1058 let grid = service
1059 .compute_extended_risk_grid_from_snapshot(&snapshot, market_state)
1060 .expect("extended risk grid should succeed");
1061
1062 let perp_row = grid
1063 .instruments
1064 .iter()
1065 .find(|row| row.symbol == "BTC-PERP")
1066 .expect("perp row should be present");
1067 assert_eq!(perp_row.current_value, 5.0);
1068 }
1069
1070 #[test]
1071 fn test_snapshot_pipeline_equity_uses_option_upnl_with_nonzero_entry_price() {
1072 let service = SpanMarginService::new_for_tests(create_test_config());
1073
1074 let mut portfolio = HashMap::new();
1075 portfolio.insert(
1076 "BTC".to_string(),
1077 Position {
1078 spot: dec!(50000),
1079 delta: dec!(0),
1080 perp_unrealized_pnl: Decimal::ZERO,
1081 options: vec![OptionContract {
1082 option_type: OptionType::Call,
1083 strike: dec!(50000),
1084 expiry_ts: TEST_EXPIRY_TS,
1085 expiry: dec!(0.25),
1086 quantity: dec!(1),
1087 entry_price: dec!(1000),
1088 }],
1089 },
1090 );
1091
1092 let account = Account {
1093 id: test_wallet(106),
1094 portfolio,
1095 cash: 10000.0,
1096 address: None,
1097 };
1098
1099 let details = futures::executor::block_on(service.compute_margin_for_account(&account))
1100 .expect("account margin should succeed");
1101 let expected_equity = dec!(10000) + (details.net_option_value - dec!(1000));
1102
1103 assert!(
1104 (details.equity - expected_equity).abs() < dec!(0.000000001),
1105 "equity ({}) should equal cash + option UPNL ({})",
1106 details.equity,
1107 expected_equity
1108 );
1109 }
1110
1111 #[test]
1112 fn test_snapshot_pipeline_excludes_open_orders_from_equity() {
1113 let service = SpanMarginService::new_for_tests(create_test_config());
1114
1115 let mut portfolio = HashMap::new();
1116 portfolio.insert(
1117 "BTC".to_string(),
1118 Position {
1119 spot: dec!(50000),
1120 delta: dec!(0),
1121 perp_unrealized_pnl: Decimal::ZERO,
1122 options: vec![OptionContract {
1123 option_type: OptionType::Call,
1124 strike: dec!(50000.5),
1125 expiry_ts: TEST_EXPIRY_TS,
1126 expiry: dec!(0.25),
1127 quantity: dec!(1),
1128 entry_price: dec!(1000),
1129 }],
1130 },
1131 );
1132
1133 let account = Account {
1134 id: test_wallet(104),
1135 portfolio,
1136 cash: 10000.0,
1137 address: None,
1138 };
1139
1140 let executed_only_snapshot = snapshot_from_account(&account);
1141 let executed_only_market_state = market_state_from_snapshot(
1142 &executed_only_snapshot,
1143 service.config().portfolio_margin_config(),
1144 );
1145 let executed_only_details = service
1146 .compute_margin_from_snapshot(&executed_only_snapshot, executed_only_market_state)
1147 .expect("executed-only snapshot should succeed");
1148
1149 let mut snapshot_with_open_order = executed_only_snapshot.clone();
1150 snapshot_with_open_order.underlyings[0]
1151 .hypothetical_open_order_options
1152 .push(hypercall_margin::PortfolioMarginOptionExposure {
1153 key: hypercall_margin::PortfolioMarginOptionKey {
1154 underlying: "BTC".to_string(),
1155 option_type: OptionType::Call,
1156 strike: dec!(50000.5),
1157 expiry_ts: TEST_EXPIRY_TS,
1158 },
1159 expiry_years: dec!(0.25),
1160 quantity: dec!(1),
1161 entry_price: dec!(1200),
1162 source: hypercall_margin::SnapshotComponentKind::OpenOrders,
1163 });
1164 let market_state_with_open_order = market_state_from_snapshot(
1165 &snapshot_with_open_order,
1166 service.config().portfolio_margin_config(),
1167 );
1168 let details_with_open_order = service
1169 .compute_margin_from_snapshot(&snapshot_with_open_order, market_state_with_open_order)
1170 .expect("snapshot with open order should succeed");
1171
1172 assert_eq!(executed_only_details.equity, details_with_open_order.equity);
1173 assert_eq!(
1174 executed_only_details.net_option_value,
1175 details_with_open_order.net_option_value
1176 );
1177 }
1178
1179 #[test]
1180 fn test_snapshot_pipeline_open_perp_overlay_changes_im_not_equity() {
1181 let service = SpanMarginService::new_for_tests(create_test_config());
1182 let wallet = test_wallet(107);
1183 let executed_snapshot = PortfolioMarginSnapshot {
1184 wallet,
1185 cash_balance: dec!(1000),
1186 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
1187 underlying: "BTC".to_string(),
1188 spot_price: dec!(100),
1189 executed_options: Vec::new(),
1190 hypothetical_open_order_options: Vec::new(),
1191 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
1192 underlying: "BTC".to_string(),
1193 quantity: dec!(1),
1194 entry_price: Some(dec!(80)),
1195 unrealized_pnl: dec!(20),
1196 }],
1197 hypothetical_open_order_perps: Vec::new(),
1198 }],
1199 };
1200 let executed_market_state = market_state_from_snapshot(
1201 &executed_snapshot,
1202 service.config().portfolio_margin_config(),
1203 );
1204 let executed_details = service
1205 .compute_margin_from_snapshot(&executed_snapshot, executed_market_state)
1206 .expect("executed snapshot should succeed");
1207
1208 let mut snapshot_with_open_order = executed_snapshot.clone();
1209 snapshot_with_open_order.underlyings[0]
1210 .hypothetical_open_order_perps
1211 .push(hypercall_margin::PortfolioMarginPerpExposure {
1212 underlying: "BTC".to_string(),
1213 quantity: dec!(1),
1214 entry_price: None,
1215 unrealized_pnl: dec!(0),
1216 });
1217 let market_state_with_open_order = market_state_from_snapshot(
1218 &snapshot_with_open_order,
1219 service.config().portfolio_margin_config(),
1220 );
1221 let details_with_open_order = service
1222 .compute_margin_from_snapshot(&snapshot_with_open_order, market_state_with_open_order)
1223 .expect("snapshot with open perp order should succeed");
1224
1225 assert_eq!(executed_details.equity, dec!(1020));
1226 assert_eq!(executed_details.equity, details_with_open_order.equity);
1227 assert!(
1228 details_with_open_order.initial_margin_required
1229 > executed_details.initial_margin_required
1230 );
1231 }
1232
1233 #[test]
1234 fn test_extended_grid_nets_split_perps_before_threshold() {
1235 let mut config = create_test_config();
1236 config.delta_threshold = 0.5;
1237 let service = SpanMarginService::new_for_tests(config);
1238 let snapshot = PortfolioMarginSnapshot {
1239 wallet: test_wallet(108),
1240 cash_balance: dec!(0),
1241 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
1242 underlying: "BTC".to_string(),
1243 spot_price: dec!(100),
1244 executed_options: Vec::new(),
1245 hypothetical_open_order_options: Vec::new(),
1246 executed_perps: vec![
1247 hypercall_margin::PortfolioMarginPerpExposure {
1248 underlying: "BTC".to_string(),
1249 quantity: dec!(0.3),
1250 entry_price: Some(dec!(100)),
1251 unrealized_pnl: dec!(0),
1252 },
1253 hypercall_margin::PortfolioMarginPerpExposure {
1254 underlying: "BTC".to_string(),
1255 quantity: dec!(0.3),
1256 entry_price: Some(dec!(100)),
1257 unrealized_pnl: dec!(0),
1258 },
1259 ],
1260 hypothetical_open_order_perps: Vec::new(),
1261 }],
1262 };
1263 let market_state =
1264 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1265
1266 let grid = service
1267 .compute_extended_risk_grid_from_snapshot(&snapshot, market_state)
1268 .expect("split perps should produce an aggregated perp row");
1269
1270 let perp_row = grid
1271 .instruments
1272 .iter()
1273 .find(|row| row.symbol == "BTC-PERP")
1274 .expect("net perp row should be present");
1275
1276 assert_eq!(perp_row.amount, 0.6);
1277 assert_eq!(grid.instruments.len(), 1);
1278 assert!(
1279 grid.total_pnls.iter().any(|pnl| pnl.abs() > 0.0),
1280 "net perp row should contribute non-zero scenario pnl"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_snapshot_pipeline_deterministic_replay_preserves_gamma_and_worst_case() {
1286 let service = SpanMarginService::new_for_tests(create_test_config());
1287 let snapshot = PortfolioMarginSnapshot {
1288 wallet: test_wallet(181),
1289 cash_balance: dec!(10000),
1290 underlyings: vec![hypercall_margin::PortfolioMarginUnderlyingSnapshot {
1291 underlying: "BTC".to_string(),
1292 spot_price: dec!(100000),
1293 executed_options: vec![hypercall_margin::PortfolioMarginOptionExposure {
1294 key: hypercall_margin::PortfolioMarginOptionKey {
1295 underlying: "BTC".to_string(),
1296 option_type: OptionType::Call,
1297 strike: dec!(100000),
1298 expiry_ts: NEAR_EXPIRY_TS,
1299 },
1300 expiry_years: expiry_years_from_timestamps(FIXED_NOW_TS, NEAR_EXPIRY_TS),
1301 quantity: dec!(-1),
1302 entry_price: dec!(5000),
1303 source: hypercall_margin::SnapshotComponentKind::ExecutedPositions,
1304 }],
1305 hypothetical_open_order_options: Vec::new(),
1306 executed_perps: vec![hypercall_margin::PortfolioMarginPerpExposure {
1307 underlying: "BTC".to_string(),
1308 quantity: dec!(-0.4),
1309 entry_price: Some(dec!(99000)),
1310 unrealized_pnl: dec!(750),
1311 }],
1312 hypothetical_open_order_perps: Vec::new(),
1313 }],
1314 };
1315
1316 let first_market_state =
1317 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1318 let second_market_state =
1319 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1320 let first = service
1321 .compute_margin_from_snapshot_at(&snapshot, first_market_state, FIXED_NOW_TS)
1322 .expect("first deterministic replay should succeed");
1323 let second = service
1324 .compute_margin_from_snapshot_at(&snapshot, second_market_state, FIXED_NOW_TS)
1325 .expect("second deterministic replay should succeed");
1326
1327 assert_eq!(first.equity, second.equity);
1328 assert_eq!(first.scanning_risk, second.scanning_risk);
1329 assert_eq!(first.option_floor, second.option_floor);
1330 assert_eq!(first.gamma_overlay, second.gamma_overlay);
1331 assert_eq!(
1332 first.initial_margin_required,
1333 second.initial_margin_required
1334 );
1335 assert_eq!(
1336 first.maintenance_margin_required,
1337 second.maintenance_margin_required
1338 );
1339
1340 let first_grid = service
1341 .compute_extended_risk_grid_from_snapshot(
1342 &snapshot,
1343 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config()),
1344 )
1345 .expect("first extended grid replay should succeed");
1346 let second_grid = service
1347 .compute_extended_risk_grid_from_snapshot(
1348 &snapshot,
1349 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config()),
1350 )
1351 .expect("second extended grid replay should succeed");
1352
1353 assert_eq!(first_grid.total_pnls, second_grid.total_pnls);
1354 assert_eq!(
1355 first_grid.worst_scenario_index,
1356 second_grid.worst_scenario_index
1357 );
1358 assert_eq!(
1359 first_grid.worst_scenario_pnl,
1360 second_grid.worst_scenario_pnl
1361 );
1362 assert_eq!(first_grid.instruments.len(), second_grid.instruments.len());
1363 for (left, right) in first_grid
1364 .instruments
1365 .iter()
1366 .zip(second_grid.instruments.iter())
1367 {
1368 assert_eq!(left.symbol, right.symbol);
1369 assert_eq!(left.scenario_pnls, right.scenario_pnls);
1370 }
1371 }
1372
1373 #[test]
1374 fn test_snapshot_pipeline_im_uses_option_floor_plus_gamma_when_floor_dominates() {
1375 let service = SpanMarginService::new_for_tests(create_low_shock_config());
1376 let snapshot = make_near_expiry_vertical_spread_snapshot();
1377 let market_state =
1378 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1379
1380 let details = service
1381 .compute_margin_from_snapshot_at(&snapshot, market_state, FIXED_NOW_TS)
1382 .expect("low-shock margin should succeed");
1383
1384 assert!(
1385 details.scanning_risk < details.option_floor,
1386 "expected option_floor to dominate low-shock book, scanning_risk={} option_floor={}",
1387 details.scanning_risk,
1388 details.option_floor
1389 );
1390 assert!(details.gamma_overlay > Decimal::ZERO);
1391 let expected_im = details.option_floor + details.gamma_overlay;
1392 assert!(
1393 (details.initial_margin_required - expected_im).abs() < dec!(0.000000001),
1394 "expected IM={} to match option_floor + gamma_overlay={}",
1395 details.initial_margin_required,
1396 expected_im
1397 );
1398 }
1399
1400 #[test]
1401 fn test_snapshot_pipeline_im_uses_scanning_risk_plus_gamma_when_scanning_dominates() {
1402 let service = SpanMarginService::new_for_tests(create_high_shock_config());
1403 let snapshot = make_near_expiry_short_snapshot();
1404 let market_state =
1405 market_state_from_snapshot(&snapshot, service.config().portfolio_margin_config());
1406
1407 let details = service
1408 .compute_margin_from_snapshot_at(&snapshot, market_state, FIXED_NOW_TS)
1409 .expect("high-shock margin should succeed");
1410
1411 assert!(
1412 details.scanning_risk > details.option_floor,
1413 "expected scanning_risk to dominate high-shock book, scanning_risk={} option_floor={}",
1414 details.scanning_risk,
1415 details.option_floor
1416 );
1417 assert!(details.gamma_overlay > Decimal::ZERO);
1418 let expected_im = details.scanning_risk + details.gamma_overlay;
1419 assert!(
1420 (details.initial_margin_required - expected_im).abs() < dec!(0.000000001),
1421 "expected IM={} to match scanning_risk + gamma_overlay={}",
1422 details.initial_margin_required,
1423 expected_im
1424 );
1425 }
1426
1427 #[test]
1428 fn test_compute_span_with_short_options() {
1429 let service = SpanMarginService::new_for_tests(create_test_config());
1430
1431 let mut portfolio = HashMap::new();
1432 portfolio.insert(
1433 "BTC".to_string(),
1434 Position {
1435 spot: dec!(50000),
1436 delta: dec!(0),
1437 perp_unrealized_pnl: Decimal::ZERO,
1438 options: vec![OptionContract {
1439 option_type: OptionType::Call,
1440 strike: dec!(50000),
1441 expiry_ts: TEST_EXPIRY_TS,
1442 expiry: dec!(0.25),
1443 quantity: dec!(-10), entry_price: dec!(0), }],
1446 },
1447 );
1448
1449 let account = Account {
1450 id: test_wallet(100),
1451 portfolio,
1452 cash: 10000.0,
1453 address: None,
1454 };
1455
1456 let results = service
1457 .compute_span(&[account])
1458 .expect("short compute_span should succeed");
1459 assert_eq!(results.len(), 1);
1460 assert_eq!(results[0].account_id, test_wallet(100));
1461 assert!(results[0].scanning_risk > Decimal::ZERO);
1462 assert!(results[0].initial_margin_required > Decimal::ZERO);
1463 }
1464
1465 #[tokio::test]
1466 async fn test_margin_service_uses_live_iv_over_config_vol() {
1467 let mut config = create_test_config();
1468 config.base_volatility = 0.10;
1469 let oracle = Arc::new(TestRiskVolOracle::with_iv(1.20));
1470 let service = SpanMarginService::new_with_vol_oracle(config.clone(), oracle);
1471
1472 let account = create_long_call_account(10_000.0, 50_000.0, 1.0);
1473 let margin = service
1474 .compute_margin_for_account(&account)
1475 .await
1476 .expect("margin should compute");
1477
1478 let expected_price = black_scholes_with_moments(
1479 &OptionType::Call,
1480 50_000.0,
1481 50_000.0,
1482 0.25,
1483 config.risk_free_rate,
1484 1.20,
1485 config.base_skew,
1486 config.base_excess_kurtosis,
1487 );
1488 let expected_value = Decimal::from_f64_retain(expected_price).unwrap();
1489 assert!(
1490 (margin.net_option_value - expected_value).abs() < dec!(0.000000001),
1491 "net option value should use live IV, got {} expected {}",
1492 margin.net_option_value,
1493 expected_value
1494 );
1495 }
1496
1497 #[tokio::test]
1498 async fn test_margin_service_fails_closed_when_vol_missing() {
1499 let oracle = Arc::new(TestRiskVolOracle::with_error(
1500 VolLookupError::MissingSurface {
1501 underlying: "BTC".to_string(),
1502 provider: VolProviderKind::BlockScholes,
1503 strike: 50_000.0,
1504 expiry_ts: TEST_EXPIRY_TS,
1505 },
1506 ));
1507 let service = SpanMarginService::new_with_vol_oracle(create_test_config(), oracle);
1508
1509 let account = create_long_call_account(10_000.0, 50_000.0, 1.0);
1510 let err = service
1511 .compute_margin_for_account(&account)
1512 .await
1513 .expect_err("missing vol must fail closed");
1514 assert!(matches!(
1515 err,
1516 MarginError::VolLookup(VolLookupError::MissingSurface { .. })
1517 ));
1518 }
1519
1520 #[tokio::test]
1529 async fn test_margin_service_trait_computes_margin_for_long_only() {
1530 let service = SpanMarginService::new_for_tests(create_test_config());
1531
1532 let mut portfolio = HashMap::new();
1533 portfolio.insert(
1534 "BTC".to_string(),
1535 Position {
1536 spot: dec!(50000),
1537 delta: dec!(0),
1538 perp_unrealized_pnl: Decimal::ZERO,
1539 options: vec![OptionContract {
1540 option_type: OptionType::Call,
1541 strike: dec!(50000),
1542 expiry_ts: TEST_EXPIRY_TS,
1543 expiry: dec!(0.25),
1544 quantity: dec!(10), entry_price: dec!(0), }],
1547 },
1548 );
1549
1550 let account = Account {
1551 id: test_wallet(100),
1552 portfolio,
1553 cash: 10000.0,
1554 address: None,
1555 };
1556
1557 let result = service.compute_margin_for_account(&account).await;
1558 assert!(result.is_ok(), "Long positions should return Ok");
1559
1560 let margin = result.unwrap();
1561
1562 assert!(
1565 margin.scanning_risk > Decimal::ZERO,
1566 "Long call should have positive scanning_risk from spot-down scenario. Got {}",
1567 margin.scanning_risk
1568 );
1569 assert!(
1570 margin.initial_margin_required > Decimal::ZERO,
1571 "Long call should have positive IM. Got {}",
1572 margin.initial_margin_required
1573 );
1574
1575 assert!(
1577 margin.net_option_value > Decimal::ZERO,
1578 "Long ATM call should have positive MTM value. Got {}",
1579 margin.net_option_value
1580 );
1581
1582 let expected_equity = dec!(10000) + margin.net_option_value;
1585 assert!(
1586 (margin.equity - expected_equity).abs() < dec!(0.000000001),
1587 "equity ({}) should equal cash + MTM ({})",
1588 margin.equity,
1589 expected_equity
1590 );
1591 }
1592
1593 #[tokio::test]
1594 async fn test_margin_service_trait_returns_some_for_short() {
1595 let service = SpanMarginService::new_for_tests(create_test_config());
1596
1597 let mut portfolio = HashMap::new();
1598 portfolio.insert(
1599 "BTC".to_string(),
1600 Position {
1601 spot: dec!(50000),
1602 delta: dec!(0),
1603 perp_unrealized_pnl: Decimal::ZERO,
1604 options: vec![OptionContract {
1605 option_type: OptionType::Call,
1606 strike: dec!(50000),
1607 expiry_ts: TEST_EXPIRY_TS,
1608 expiry: dec!(0.25),
1609 quantity: dec!(-10), entry_price: dec!(0), }],
1612 },
1613 );
1614
1615 let account = Account {
1616 id: test_wallet(100),
1617 portfolio,
1618 cash: 10000.0,
1619 address: None,
1620 };
1621
1622 let result = service.compute_margin_for_account(&account).await;
1623 assert!(result.is_ok());
1624 let details = result.unwrap();
1625 assert_eq!(details.account_id, test_wallet(100));
1626 assert!(details.scanning_risk > Decimal::ZERO);
1627 }
1628
1629 #[tokio::test]
1635 async fn test_new_im_mm_fields_with_divergence() {
1636 let service = SpanMarginService::new_for_tests(create_test_config());
1637
1638 let mut portfolio = HashMap::new();
1639 portfolio.insert(
1640 "BTC".to_string(),
1641 Position {
1642 spot: dec!(50000),
1643 delta: dec!(0),
1644 perp_unrealized_pnl: Decimal::ZERO,
1645 options: vec![OptionContract {
1646 option_type: OptionType::Call,
1647 strike: dec!(50000),
1648 expiry_ts: TEST_EXPIRY_TS,
1649 expiry: dec!(0.25),
1650 quantity: dec!(-10), entry_price: dec!(0), }],
1653 },
1654 );
1655
1656 let account_cash_f64 = 50000.0;
1657 let account_cash = dec!(50000);
1658 let account = Account {
1659 id: test_wallet(101),
1660 portfolio,
1661 cash: account_cash_f64,
1662 address: None,
1663 };
1664
1665 let result = service.compute_margin_for_account(&account).await;
1666 assert!(result.is_ok());
1667 let details = result.unwrap();
1668
1669 let expected_initial_margin =
1670 details.scanning_risk.max(details.option_floor) + details.gamma_overlay;
1671 assert!(
1672 (details.initial_margin_required - expected_initial_margin).abs() < dec!(0.000000001),
1673 "initial_margin_required ({}) should equal max(scanning_risk={}, option_floor={}) + gamma_overlay={}",
1674 details.initial_margin_required,
1675 details.scanning_risk,
1676 details.option_floor,
1677 details.gamma_overlay
1678 );
1679
1680 let expected_mm =
1682 details.initial_margin_required * Decimal::from_f64_retain(MM_TO_IM_RATIO).unwrap();
1683 assert!(
1684 (details.maintenance_margin_required - expected_mm).abs() < dec!(0.000000001),
1685 "maintenance_margin_required ({}) should equal 0.85 * IM ({})",
1686 details.maintenance_margin_required,
1687 expected_mm
1688 );
1689
1690 assert!(
1692 details.maintenance_margin_required < details.initial_margin_required,
1693 "MM ({}) should be less than IM ({})",
1694 details.maintenance_margin_required,
1695 details.initial_margin_required
1696 );
1697
1698 let expected_equity = account_cash + details.net_option_value; assert!(
1702 (details.equity - expected_equity).abs() < dec!(0.000000001),
1703 "equity ({}) should equal cash ({}) + MTM ({})",
1704 details.equity,
1705 account_cash,
1706 details.net_option_value
1707 );
1708 assert!(
1710 details.net_option_value < Decimal::ZERO,
1711 "Short call should have negative MTM. Got {}",
1712 details.net_option_value
1713 );
1714 }
1715
1716 #[test]
1718 fn test_compute_span_populates_new_fields() {
1719 let service = SpanMarginService::new_for_tests(create_test_config());
1720
1721 let mut portfolio = HashMap::new();
1722 portfolio.insert(
1723 "BTC".to_string(),
1724 Position {
1725 spot: dec!(50000),
1726 delta: dec!(0),
1727 perp_unrealized_pnl: Decimal::ZERO,
1728 options: vec![OptionContract {
1729 option_type: OptionType::Call,
1730 strike: dec!(50000),
1731 expiry_ts: TEST_EXPIRY_TS,
1732 expiry: dec!(0.25),
1733 quantity: dec!(-10), entry_price: dec!(0), }],
1736 },
1737 );
1738
1739 let account_cash_f64 = 25000.0;
1740 let account_cash = dec!(25000);
1741 let account = Account {
1742 id: test_wallet(102),
1743 portfolio,
1744 cash: account_cash_f64,
1745 address: None,
1746 };
1747
1748 let results = service
1749 .compute_span(&[account])
1750 .expect("sync compute_span should succeed");
1751 assert_eq!(results.len(), 1);
1752 let details = &results[0];
1753
1754 let expected_initial_margin =
1755 details.scanning_risk.max(details.option_floor) + details.gamma_overlay;
1756 assert!(
1757 (details.initial_margin_required - expected_initial_margin).abs() < dec!(0.000000001),
1758 "initial_margin_required ({}) should equal max(scanning_risk={}, option_floor={}) + gamma_overlay={}",
1759 details.initial_margin_required,
1760 details.scanning_risk,
1761 details.option_floor,
1762 details.gamma_overlay
1763 );
1764
1765 let expected_mm =
1767 details.initial_margin_required * Decimal::from_f64_retain(MM_TO_IM_RATIO).unwrap();
1768 assert!(
1769 (details.maintenance_margin_required - expected_mm).abs() < dec!(0.000000001),
1770 "maintenance_margin_required should equal 0.85 * IM"
1771 );
1772
1773 assert!(
1775 details.maintenance_margin_required < details.initial_margin_required,
1776 "MM should be less than IM"
1777 );
1778
1779 let expected_equity = account_cash + details.net_option_value;
1781 assert!(
1782 (details.equity - expected_equity).abs() < dec!(0.000000001),
1783 "equity ({}) should equal cash ({}) + MTM ({})",
1784 details.equity,
1785 account_cash,
1786 details.net_option_value
1787 );
1788 }
1789
1790 fn create_pm_test_config() -> Config {
1807 Config {
1808 risk_free_rate: 0.05,
1809 base_volatility: 0.8,
1810 base_skew: 0.0,
1811 base_excess_kurtosis: 0.0,
1812 scenarios: vec![
1813 Scenario {
1815 scenario_type: ScenarioType::SpotChange,
1816 value: 0.15, },
1818 Scenario {
1819 scenario_type: ScenarioType::SpotChange,
1820 value: -0.15, },
1822 Scenario {
1823 scenario_type: ScenarioType::SpotChange,
1824 value: 0.25, },
1826 Scenario {
1827 scenario_type: ScenarioType::SpotChange,
1828 value: -0.25, },
1830 Scenario {
1832 scenario_type: ScenarioType::VolChange,
1833 value: 0.25, },
1835 Scenario {
1836 scenario_type: ScenarioType::VolChange,
1837 value: -0.25, },
1839 ],
1840 delta_threshold: 0.0001,
1841 strike_match_tolerance: 0.01,
1842 expiry_match_tolerance_years: 0.001,
1843 allow_standard_margin_shorts: false,
1844 fee_config: FeeConfig::default(),
1845 }
1846 }
1847
1848 fn create_long_call_account(cash: f64, spot: f64, quantity: f64) -> Account {
1850 let spot_dec = Decimal::from_f64_retain(spot).unwrap_or(Decimal::ZERO);
1851 let quantity_dec = Decimal::from_f64_retain(quantity).unwrap_or(Decimal::ZERO);
1852 let mut portfolio = HashMap::new();
1853 portfolio.insert(
1854 "BTC".to_string(),
1855 Position {
1856 spot: spot_dec,
1857 delta: Decimal::ZERO,
1858 perp_unrealized_pnl: Decimal::ZERO,
1859 options: vec![OptionContract {
1860 option_type: OptionType::Call,
1861 strike: spot_dec, expiry_ts: TEST_EXPIRY_TS,
1863 expiry: dec!(0.25), quantity: quantity_dec, entry_price: Decimal::ZERO, }],
1867 },
1868 );
1869 Account {
1870 id: test_wallet(103),
1871 portfolio,
1872 cash,
1873 address: None,
1874 }
1875 }
1876
1877 fn create_short_call_account(cash: f64, spot: f64, quantity: f64) -> Account {
1879 let spot_dec = Decimal::from_f64_retain(spot).unwrap_or(Decimal::ZERO);
1880 let quantity_dec = Decimal::from_f64_retain(quantity).unwrap_or(Decimal::ZERO);
1881 let mut portfolio = HashMap::new();
1882 portfolio.insert(
1883 "BTC".to_string(),
1884 Position {
1885 spot: spot_dec,
1886 delta: Decimal::ZERO,
1887 perp_unrealized_pnl: Decimal::ZERO,
1888 options: vec![OptionContract {
1889 option_type: OptionType::Call,
1890 strike: spot_dec, expiry_ts: TEST_EXPIRY_TS,
1892 expiry: dec!(0.25), quantity: -quantity_dec, entry_price: Decimal::ZERO, }],
1896 },
1897 );
1898 Account {
1899 id: test_wallet(104),
1900 portfolio,
1901 cash,
1902 address: None,
1903 }
1904 }
1905
1906 #[tokio::test]
1914 async fn test_pm_long_call_has_nonzero_im() {
1915 let config = create_pm_test_config();
1916 let service = SpanMarginService::new_for_tests(config);
1917
1918 let account = create_long_call_account(100_000.0, 100_000.0, 10.0);
1919
1920 let result = service.compute_margin_for_account(&account).await;
1921 assert!(
1922 result.is_ok(),
1923 "Should return Ok for any non-empty portfolio"
1924 );
1925
1926 let details = result.unwrap();
1927
1928 println!("PM Test - Long Call:");
1930 println!(" scanning_risk: {}", details.scanning_risk);
1931 println!(" net_option_value: {}", details.net_option_value);
1932 println!(
1933 " initial_margin_required: {}",
1934 details.initial_margin_required
1935 );
1936
1937 assert!(
1939 details.initial_margin_required > Decimal::ZERO,
1940 "Long call should have nonzero IM (scenario loss from spot down). Got IM={}",
1941 details.initial_margin_required
1942 );
1943
1944 assert!(
1945 details.net_option_value > Decimal::ZERO,
1946 "ATM long call should have positive MTM value. Got {}",
1947 details.net_option_value
1948 );
1949 }
1950
1951 #[tokio::test]
1959 async fn test_pm_short_margin_greater_than_long_margin() {
1960 let config = create_pm_test_config();
1961 let service = SpanMarginService::new_for_tests(config);
1962
1963 let spot = 100_000.0;
1964 let quantity = 10.0;
1965 let cash = 100_000.0;
1966
1967 let long_account = create_long_call_account(cash, spot, quantity);
1968 let short_account = create_short_call_account(cash, spot, quantity);
1969
1970 let long_result = service.compute_margin_for_account(&long_account).await;
1971 let short_result = service.compute_margin_for_account(&short_account).await;
1972
1973 assert!(long_result.is_ok(), "Long should return Ok");
1974 assert!(short_result.is_ok(), "Short should return Ok");
1975
1976 let long_details = long_result.unwrap();
1977 let short_details = short_result.unwrap();
1978
1979 println!("PM Test - Long vs Short:");
1981 println!(" Long IM: {}", long_details.initial_margin_required);
1982 println!(" Short IM: {}", short_details.initial_margin_required);
1983
1984 assert!(
1986 long_details.initial_margin_required > Decimal::ZERO,
1987 "Long call must have nonzero IM under PM. Got {}",
1988 long_details.initial_margin_required
1989 );
1990 assert!(
1991 short_details.initial_margin_required > Decimal::ZERO,
1992 "Short call must have nonzero IM. Got {}",
1993 short_details.initial_margin_required
1994 );
1995
1996 assert!(
1998 short_details.initial_margin_required > long_details.initial_margin_required,
1999 "Short IM ({}) should be greater than Long IM ({})",
2000 short_details.initial_margin_required,
2001 long_details.initial_margin_required
2002 );
2003 }
2004
2005 #[tokio::test]
2013 async fn test_pm_spread_has_less_margin_than_naked_short() {
2014 let config = create_pm_test_config();
2015 let service = SpanMarginService::new_for_tests(config);
2016
2017 let spot = 100_000.0;
2018 let spot_dec = dec!(100000);
2019 let cash = 100_000.0;
2020
2021 let short_account = create_short_call_account(cash, spot, 10.0);
2023
2024 let mut spread_portfolio = HashMap::new();
2027 spread_portfolio.insert(
2028 "BTC".to_string(),
2029 Position {
2030 spot: spot_dec,
2031 delta: Decimal::ZERO,
2032 perp_unrealized_pnl: Decimal::ZERO,
2033 options: vec![
2034 OptionContract {
2036 option_type: OptionType::Call,
2037 strike: spot_dec,
2038 expiry_ts: TEST_EXPIRY_TS,
2039 expiry: dec!(0.25),
2040 quantity: dec!(-10),
2041 entry_price: Decimal::ZERO, },
2043 OptionContract {
2045 option_type: OptionType::Call,
2046 strike: spot_dec * dec!(1.10),
2047 expiry_ts: TEST_EXPIRY_TS,
2048 expiry: dec!(0.25),
2049 quantity: dec!(10),
2050 entry_price: Decimal::ZERO, },
2052 ],
2053 },
2054 );
2055 let spread_account = Account {
2056 id: test_wallet(105),
2057 portfolio: spread_portfolio,
2058 cash,
2059 address: None,
2060 };
2061
2062 let short_result = service.compute_margin_for_account(&short_account).await;
2063 let spread_result = service.compute_margin_for_account(&spread_account).await;
2064
2065 assert!(short_result.is_ok(), "Naked short should return Ok");
2066 assert!(spread_result.is_ok(), "Spread should return Ok");
2067
2068 let short_details = short_result.unwrap();
2069 let spread_details = spread_result.unwrap();
2070
2071 println!("PM Test - Spread vs Naked Short:");
2073 println!(
2074 " Naked Short IM: {}",
2075 short_details.initial_margin_required
2076 );
2077 println!(" Spread IM: {}", spread_details.initial_margin_required);
2078 println!(" Spread scanning_risk: {}", spread_details.scanning_risk);
2079 println!(
2080 " Spread net_option_value: {}",
2081 spread_details.net_option_value
2082 );
2083
2084 assert!(
2086 short_details.initial_margin_required > Decimal::ZERO,
2087 "Naked short must have positive IM"
2088 );
2089 assert!(
2090 spread_details.initial_margin_required > Decimal::ZERO,
2091 "Spread with short leg should have positive IM"
2092 );
2093
2094 assert!(
2099 spread_details.initial_margin_required < short_details.initial_margin_required,
2100 "Spread IM ({}) should be less than Naked Short IM ({})",
2101 spread_details.initial_margin_required,
2102 short_details.initial_margin_required
2103 );
2104 }
2105
2106 #[tokio::test]
2111 async fn test_pm_empty_portfolio_returns_some_with_zero_margin() {
2112 let config = create_pm_test_config();
2113 let service = SpanMarginService::new_for_tests(config);
2114
2115 let account = Account {
2116 id: test_wallet(106),
2117 portfolio: HashMap::new(),
2118 cash: 50_000.0,
2119 address: None,
2120 };
2121
2122 let result = service.compute_margin_for_account(&account).await;
2123
2124 assert!(result.is_ok(), "Empty portfolio should return Ok");
2125
2126 let details = result.unwrap();
2127 assert_eq!(
2128 details.initial_margin_required,
2129 Decimal::ZERO,
2130 "Empty portfolio should have zero IM"
2131 );
2132 assert_eq!(
2133 details.equity,
2134 dec!(50000),
2135 "Equity should equal cash for empty portfolio"
2136 );
2137 }
2138
2139 struct PerUnderlyingTestOracle {
2146 healthy_iv: f64,
2147 unhealthy_underlyings: std::collections::HashSet<String>,
2148 }
2149
2150 impl PerUnderlyingTestOracle {
2151 fn new(healthy_iv: f64, unhealthy: &[&str]) -> Self {
2152 Self {
2153 healthy_iv,
2154 unhealthy_underlyings: unhealthy.iter().map(|s| s.to_string()).collect(),
2155 }
2156 }
2157 }
2158
2159 impl RiskVolOracle for PerUnderlyingTestOracle {
2160 fn get_iv(
2161 &self,
2162 underlying: &str,
2163 _strike: f64,
2164 _expiry_ts: i64,
2165 ) -> Result<f64, VolLookupError> {
2166 if self.unhealthy_underlyings.contains(underlying) {
2167 Err(VolLookupError::UnhealthyProvider {
2168 underlying: underlying.to_string(),
2169 provider: VolProviderKind::BlockScholes,
2170 reason: "test: simulated unhealthy".to_string(),
2171 })
2172 } else {
2173 Ok(self.healthy_iv)
2174 }
2175 }
2176
2177 fn statuses(&self) -> Vec<VolOracleStatus> {
2178 vec![]
2179 }
2180 }
2181
2182 #[tokio::test]
2183 async fn test_span_rejects_when_any_underlying_unhealthy() {
2184 let oracle = Arc::new(PerUnderlyingTestOracle::new(0.80, &["GOLD"]));
2185 let service = SpanMarginService::new_with_vol_oracle(create_test_config(), oracle);
2186
2187 let mut portfolio = HashMap::new();
2188 portfolio.insert(
2189 "BTC".to_string(),
2190 Position {
2191 spot: dec!(50000),
2192 delta: dec!(0),
2193 perp_unrealized_pnl: Decimal::ZERO,
2194 options: vec![OptionContract {
2195 option_type: OptionType::Call,
2196 strike: dec!(50000),
2197 expiry_ts: TEST_EXPIRY_TS,
2198 expiry: dec!(0.25),
2199 quantity: dec!(-1),
2200 entry_price: dec!(5000),
2201 }],
2202 },
2203 );
2204 portfolio.insert(
2205 "GOLD".to_string(),
2206 Position {
2207 spot: dec!(3000),
2208 delta: dec!(0),
2209 perp_unrealized_pnl: Decimal::ZERO,
2210 options: vec![OptionContract {
2211 option_type: OptionType::Put,
2212 strike: dec!(3000),
2213 expiry_ts: TEST_EXPIRY_TS,
2214 expiry: dec!(0.25),
2215 quantity: dec!(-2),
2216 entry_price: dec!(200),
2217 }],
2218 },
2219 );
2220
2221 let account = Account {
2222 id: test_wallet(200),
2223 portfolio,
2224 cash: 100_000.0,
2225 address: None,
2226 };
2227
2228 let result = service.compute_margin_for_account(&account).await;
2229 assert!(
2230 result.is_err(),
2231 "SPAN must fail when any underlying has unhealthy IV"
2232 );
2233 }
2234
2235 #[tokio::test]
2236 async fn test_span_all_healthy_no_fallback_used() {
2237 let oracle = Arc::new(PerUnderlyingTestOracle::new(0.80, &[]));
2238 let service = SpanMarginService::new_with_vol_oracle(create_test_config(), oracle);
2239
2240 let account = create_long_call_account(10_000.0, 50_000.0, 1.0);
2241 let result = service.compute_margin_for_account(&account).await;
2242 assert!(result.is_ok(), "All-healthy compute must succeed");
2243 }
2244}