Skip to main content

hypercall/rsm/margin_service/
span_margin_service.rs

1//! SpanMarginService - SPAN-style margin calculation implementation
2//!
3//! This service implements scenario-based margin calculations similar to
4//! CME SPAN (Standard Portfolio Analysis of Risk).
5
6use 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
29/// RAII guard for margin operation observability.
30///
31/// Records a counter increment and histogram observation on drop.
32struct 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
180/// SPAN-style margin calculation service.
181///
182/// This consolidates margin logic that was previously scattered across UnifiedEngine,
183/// providing a single source of truth for margin computations.
184///
185/// # Architecture
186///
187/// The service uses scenario-based risk analysis:
188/// - Evaluates portfolio P&L across multiple market scenarios (spot moves, vol changes, etc.)
189/// - Includes both delta (perp) P&L and options P&L in scenario evaluation
190/// - Computes "scanning risk" as the worst-case loss across all scenarios
191///
192/// # Portfolio Margin (PM) Semantics
193///
194/// Any non-empty portfolio goes through SPAN scenario evaluation:
195/// - Long options: scenario loss when spot drops (call loses value)
196/// - Short options: scenario loss when spot rises (unlimited upside risk)
197/// - Spreads: hedging benefits reduce net scenario loss
198///
199/// Only truly empty portfolios (no positions) return zero margin immediately.
200pub struct SpanMarginService {
201    config: Config,
202    vol_oracle: SharedVolOracle,
203}
204
205impl SpanMarginService {
206    /// Create a new SpanMarginService with the given configuration.
207    pub fn new(config: Config) -> Self {
208        Self::new_fail_closed(config)
209    }
210
211    /// Create a fail-closed SpanMarginService for production wiring.
212    pub fn new_fail_closed(config: Config) -> Self {
213        Self::new_with_vol_oracle(config, Arc::new(MissingRiskVolOracle))
214    }
215
216    /// Create a fixed-vol SPAN service for tests that do not exercise live vol wiring.
217    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    // ===== Public API =====
295
296    /// Compute margin details for one or more accounts using Portfolio Margin semantics.
297    ///
298    /// # Portfolio Margin Behavior
299    ///
300    /// All accounts with positions (long or short) are included in the result.
301    /// Empty portfolios return zero margin. This follows PM semantics where:
302    /// - equity = cash + executed option UPNL + executed perp UPNL
303    ///   - `cash` comes from the engine-owned balance snapshot
304    ///   - option UPNL is computed from Black-Scholes valuations
305    ///   - perp UPNL comes from executed perp state only
306    /// - IM = max(scanning_risk, option_floor) + gamma_overlay
307    /// - MM = IM * 0.85
308    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    // ===== Risk Grid API =====
328
329    /// Compute the risk grid showing scenario PnL for each configured scenario.
330    ///
331    /// This provides introspection into how SPAN scenarios affect the portfolio,
332    /// similar to Deribit's risk grid endpoint.
333    ///
334    /// # Returns
335    /// A vector of scenario PnL entries, one per configured scenario.
336    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    /// Compute extended risk grid with per-instrument breakdown.
343    ///
344    /// This provides a full risk matrix like Deribit's Extended Risk Matrix,
345    /// showing P&L for each position under each scenario.
346    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    // ===== Private Helper Methods =====
406
407    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    /// Check if account has any positions (options or delta exposure).
427    ///
428    /// Returns true if:
429    /// - Any options positions exist (long or short)
430    /// - Any significant delta (perp) exposure exists
431    ///
432    /// Used to determine if SPAN scenario evaluation should run.
433    /// Empty portfolios can skip SPAN and return zero margin immediately.
434    ///
435    /// Note: We iterate by underlying (e.g., "BTC", "ETH"), not individual symbols.
436    /// Each Position contains all options for that underlying plus delta exposure.
437    /// No symbol-level filtering is needed here since we're checking existence, not value.
438    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        // Empty portfolio: return zeros immediately (no SPAN needed)
461        // This is the only case where we skip scenario evaluation.
462        if !self.has_positions(account) {
463            return Ok(empty_portfolio_margin_details(account));
464        }
465
466        // Portfolio Margin: ALL non-empty portfolios go through SPAN scenario evaluation
467        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),   // Long only
728                    entry_price: dec!(0), // Test: acquired at zero cost
729                }],
730            },
731        );
732
733        let account = Account {
734            id: test_wallet(100),
735            portfolio,
736            cash: 10000.0,
737            address: None,
738        };
739
740        // PM semantics: long-only portfolios also get margin computed
741        let results = service
742            .compute_span(&[account])
743            .expect("long-only compute_span should succeed");
744        assert_eq!(results.len(), 1);
745        // Long options have positive MTM and scenario-based IM
746        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),  // Short position
1444                    entry_price: dec!(0), // Test: sold at zero premium
1445                }],
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    /// Test that long-only positions now go through SPAN and have margin requirements.
1521    ///
1522    /// Under Portfolio Margin, long options have scenario risk:
1523    /// - Long calls lose value when spot drops
1524    /// - Long puts lose value when spot rises
1525    ///
1526    /// This test validates the PM semantic change from the old behavior
1527    /// where long-only positions had zero margin.
1528    #[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),   // Long only
1545                    entry_price: dec!(0), // Test: acquired at zero cost
1546                }],
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        // PM CHANGE: Long positions now have nonzero margin (scenario risk)
1563        // With ±15% spot scenarios, ATM call loses value when spot drops -15%
1564        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        // Net option value should be positive for long ATM call
1576        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        // Equity = cash + executed option UPNL + executed perp UPNL
1583        // Long call has positive MTM, so equity > cash
1584        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),  // Short position
1610                    entry_price: dec!(0), // Test: sold at zero premium
1611                }],
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    /// Test that IM/MM fields follow PM semantics.
1630    /// Invariants (Step 4 PM semantics):
1631    /// - initial_margin_required == scanning_risk (+ hypercore_margin if present)
1632    /// - maintenance_margin_required == 0.85 * IM
1633    /// - equity == account.cash (until Step 5)
1634    #[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),  // Short position
1651                    entry_price: dec!(0), // Test: sold at zero premium
1652                }],
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        // MM = MM_TO_IM_RATIO * IM (lower threshold for liquidation)
1681        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        // MM < IM (you need more to open than to maintain)
1691        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        // Equity = cash + executed option UPNL + executed perp UPNL
1699        // Short call has negative MTM (liability), so equity < cash
1700        let expected_equity = account_cash + details.net_option_value; // Both are Decimal
1701        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        // Short positions have negative MTM
1709        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 that sync compute_span also follows PM semantics.
1717    #[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),  // Short position
1734                    entry_price: dec!(0), // Test: sold at zero premium
1735                }],
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        // Verify MM = MM_TO_IM_RATIO * IM
1766        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        // Verify MM < IM
1774        assert!(
1775            details.maintenance_margin_required < details.initial_margin_required,
1776            "MM should be less than IM"
1777        );
1778
1779        // Equity = cash + executed option UPNL + executed perp UPNL
1780        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    // =========================================================================
1791    // Portfolio Margin v1 Tests (Step 1)
1792    //
1793    // These tests describe the DESIRED behavior for portfolio margin.
1794    // Initially, some may fail because the current implementation doesn't
1795    // compute margin for long-only positions.
1796    //
1797    // Goal: After completing the PM refactor:
1798    //   - Any non-empty portfolio should go through SPAN
1799    //   - Long options should have nonzero IM (scenario loss from spot down)
1800    //   - Short options should have higher IM than equivalent longs
1801    //   - Spreads should have lower IM than naked shorts
1802    // =========================================================================
1803
1804    /// Create a config with more aggressive scenarios for portfolio margin testing.
1805    /// Includes spot down -25% to ensure long calls show meaningful scenario loss.
1806    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                // Spot moves
1814                Scenario {
1815                    scenario_type: ScenarioType::SpotChange,
1816                    value: 0.15, // +15%
1817                },
1818                Scenario {
1819                    scenario_type: ScenarioType::SpotChange,
1820                    value: -0.15, // -15%
1821                },
1822                Scenario {
1823                    scenario_type: ScenarioType::SpotChange,
1824                    value: 0.25, // +25%
1825                },
1826                Scenario {
1827                    scenario_type: ScenarioType::SpotChange,
1828                    value: -0.25, // -25% (should hurt long calls)
1829                },
1830                // Vol moves
1831                Scenario {
1832                    scenario_type: ScenarioType::VolChange,
1833                    value: 0.25, // +25% vol
1834                },
1835                Scenario {
1836                    scenario_type: ScenarioType::VolChange,
1837                    value: -0.25, // -25% vol (should hurt long options)
1838                },
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    /// Helper to create an ATM long call account
1849    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, // ATM
1862                    expiry_ts: TEST_EXPIRY_TS,
1863                    expiry: dec!(0.25),         // 3 months
1864                    quantity: quantity_dec,     // positive = long
1865                    entry_price: Decimal::ZERO, // Test: acquired at zero cost
1866                }],
1867            },
1868        );
1869        Account {
1870            id: test_wallet(103),
1871            portfolio,
1872            cash,
1873            address: None,
1874        }
1875    }
1876
1877    /// Helper to create an ATM short call account
1878    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, // ATM
1891                    expiry_ts: TEST_EXPIRY_TS,
1892                    expiry: dec!(0.25),         // 3 months
1893                    quantity: -quantity_dec,    // negative = short
1894                    entry_price: Decimal::ZERO, // Test: sold at zero premium
1895                }],
1896            },
1897        );
1898        Account {
1899            id: test_wallet(104),
1900            portfolio,
1901            cash,
1902            address: None,
1903        }
1904    }
1905
1906    /// PM Test: A pure long call should have nonzero initial margin.
1907    ///
1908    /// Rationale: Even long options have risk. In a portfolio margin system,
1909    /// the margin requirement represents the worst-case scenario loss.
1910    /// A long call loses value when spot goes down, so SPAN should capture this.
1911    ///
1912    /// After Step 2 (remove short-only gating): IM > 0 for long positions. ✅
1913    #[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        // Log the actual values for debugging
1929        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        // Step 2 complete: Long calls now have scenario loss when spot drops
1938        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    /// PM Test: A short call should have higher IM than an equivalent long call.
1952    ///
1953    /// Rationale: Selling options is riskier than buying them. The max loss on
1954    /// a long call is limited to the premium paid, but a short call can lose
1955    /// theoretically unlimited amounts.
1956    ///
1957    /// After Step 2: IM(short) > IM(long) > 0 ✅
1958    #[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        // Log values for debugging
1980        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        // Both should have positive IM under PM
1985        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        // Short should be strictly greater than long (unlimited upside risk vs limited downside)
1997        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    /// PM Test: A spread (long + short same series) should have less margin than naked short.
2006    ///
2007    /// Rationale: Portfolio margin recognizes offsetting positions. If you have
2008    /// a long call at strike K and a short call at the same strike, the long
2009    /// provides a hedge for the short. The net risk is reduced.
2010    ///
2011    /// EXPECTED: IM(spread) < IM(naked_short)
2012    #[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        // Naked short: 10 short calls
2022        let short_account = create_short_call_account(cash, spot, 10.0);
2023
2024        // Spread: 5 long + 5 short at same strike (net 0)
2025        // Actually, let's do a more realistic spread: 10 short low strike, 10 long higher strike
2026        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                    // Short 10 calls at 100k
2035                    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, // Test: sold at zero premium
2042                    },
2043                    // Long 10 calls at 110k (higher strike)
2044                    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, // Test: acquired at zero cost
2051                    },
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        // Log values
2072        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        // Both should require margin (spread has short leg)
2085        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        // The spread should have LOWER margin than the naked short
2095        // because the long higher-strike call provides a hedge.
2096        // Max loss on the spread = (110k - 100k) * 10 = 100k (capped by long strike)
2097        // Max loss on naked short = theoretically unlimited
2098        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    /// PM Test: Empty portfolio with only cash should return Some with zero margin.
2107    ///
2108    /// Rationale: An account with just cash and no positions should still return
2109    /// a valid MarginDetails, but with zero margin requirements.
2110    #[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    // =========================================================================
2140    // SPAN fail-closed behavior: unhealthy vol oracle rejects margin
2141    // =========================================================================
2142
2143    /// A vol oracle that returns OK for some underlyings and Unhealthy for
2144    /// others, used to test the per-underlying fail-open behavior.
2145    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}