Skip to main content

hypercall_margin/portfolio/
settlement_pool.rs

1use crate::types::OptionType;
2use hypercall_types::WalletAddress;
3use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9/// Phase 1 settlement-pool policy knobs.
10///
11/// Utilization caps are explicit unit-interval values. The normal cap gates new
12/// timing bridges, while the crisis cap marks an already over-utilized pool as
13/// unavailable for fresh settlement classification.
14pub struct PmSettlementPoolConfig {
15    /// Multiplier applied to short-option open interest to derive the pool target.
16    pub target_short_oi_notional_multiplier: Decimal,
17    /// Utilization point where the APR curve reaches `apr_at_kink`.
18    pub utilization_kink: Decimal,
19    /// APR charged at `utilization_kink`.
20    pub apr_at_kink: Decimal,
21    /// APR charged at 100% utilization.
22    pub max_apr: Decimal,
23    /// Maximum projected utilization for a new timing bridge.
24    pub normal_utilization_cap: Decimal,
25    /// Hard utilization ceiling beyond which classification is unavailable.
26    pub crisis_utilization_cap: Decimal,
27    /// Policy bridge window in milliseconds. Phase 1 validates only.
28    pub bridge_window_ms: i64,
29    /// Nonzero policy version for deterministic replay metadata.
30    pub policy_version: u32,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34/// Point-in-time settlement-pool balances for one underlying.
35///
36/// `pool_available_usdc` is deployable cash. Active bridge and debt fields are
37/// already-fronted liabilities, so capacity is available cash plus those active
38/// liabilities.
39pub struct PmSettlementPoolSnapshot {
40    pub underlying: String,
41    pub pool_available_usdc: Decimal,
42    pub pool_target_usdc: Decimal,
43    pub active_timing_bridge_usdc: Decimal,
44    pub active_settlement_debt_usdc: Decimal,
45    pub config_version: u32,
46    pub policy_version: u32,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50/// Account facts used by pure settlement classification.
51///
52/// Monetary fields are snapshot-as-of `facts_as_of_ms`. `stale` makes the
53/// classifier return `Unavailable` before considering bridges or debt.
54pub struct PmAccountSettlementFacts {
55    pub wallet: WalletAddress,
56    pub underlying: String,
57    pub liquid_usdc: Decimal,
58    pub pm_equity_usdc: Decimal,
59    pub pm_maintenance_requirement_usdc: Decimal,
60    pub recoverable_collateral_usdc: Decimal,
61    pub facts_as_of_ms: i64,
62    pub stale: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66/// Settlement obligation for one account and expiry market.
67///
68/// `settlement_obligation_usdc` must equal `max(0, -net_pnl_usdc)`.
69pub struct PmSettlementObligation {
70    pub wallet: WalletAddress,
71    pub market_id: String,
72    pub expiry_ts_ms: i64,
73    pub underlying: String,
74    pub net_pnl_usdc: Decimal,
75    pub settlement_obligation_usdc: Decimal,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79/// Pure Phase 1 liquidity outcome for a settlement obligation.
80pub enum PmLiquidityClassification {
81    /// The obligation is zero or covered by liquid USDC.
82    Paid,
83    /// The pool can temporarily front the liquid-USDC shortfall.
84    TimingBridge,
85    /// The shortfall should be tracked as settlement debt.
86    SettlementDebt,
87    /// Required facts or usable pool capacity are unavailable.
88    Unavailable,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92/// Deterministic option key used by settlement-pool fixtures and mark maps.
93pub struct PmFixtureOptionKey {
94    pub underlying: String,
95    pub option_type: OptionType,
96    pub strike: Decimal,
97    pub expiry_ts_ms: i64,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101/// Deterministic fixture position. Negative quantity represents short exposure.
102pub struct PmFixturePosition {
103    pub key: PmFixtureOptionKey,
104    pub quantity: Decimal,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108/// Spot and option marks required by deterministic Phase 1 fixtures.
109pub struct PmMarketMarks {
110    pub spot_by_underlying: HashMap<String, Decimal>,
111    pub option_mark_by_key: HashMap<PmFixtureOptionKey, Decimal>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115/// Deterministic scenario fixture for Phase 1 settlement-pool tests.
116pub struct PmSettlementFixture {
117    pub name: &'static str,
118    pub wallet: WalletAddress,
119    pub underlying: String,
120    pub positions: Vec<PmFixturePosition>,
121    pub marks: PmMarketMarks,
122    pub cash_usdc: Decimal,
123    pub expected_short_option_oi_usdc: Decimal,
124    pub expected_pm_equity_usdc: Decimal,
125    pub expected_recoverable_collateral_usdc: Decimal,
126    pub settlement_obligation_usdc: Decimal,
127    pub expected_classification: PmLiquidityClassification,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131/// Errors returned by pure PM settlement-pool helpers.
132pub enum PmSettlementModelError {
133    InvalidConfig(&'static str),
134    NegativeAmount {
135        field: &'static str,
136        amount: Decimal,
137    },
138    InvalidUtilization(Decimal),
139    MissingSpot {
140        underlying: String,
141    },
142    MissingOptionMark {
143        underlying: String,
144        strike: Decimal,
145        expiry_ts_ms: i64,
146        option_type: OptionType,
147    },
148    UnderlyingMismatch {
149        expected: String,
150        actual: String,
151    },
152}
153
154impl std::fmt::Display for PmSettlementModelError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::InvalidConfig(message) => write!(f, "invalid PM settlement pool config: {message}"),
158            Self::NegativeAmount { field, amount } => {
159                write!(f, "negative PM settlement amount for {field}: {amount}")
160            }
161            Self::InvalidUtilization(utilization) => {
162                write!(f, "invalid PM settlement pool utilization: {utilization}")
163            }
164            Self::MissingSpot { underlying } => {
165                write!(f, "missing spot price for PM settlement underlying {underlying}")
166            }
167            Self::MissingOptionMark {
168                underlying,
169                strike,
170                expiry_ts_ms,
171                option_type,
172            } => write!(
173                f,
174                "missing option mark for PM settlement {underlying} {option_type:?} {strike} expiring {expiry_ts_ms}"
175            ),
176            Self::UnderlyingMismatch { expected, actual } => write!(
177                f,
178                "PM settlement underlying mismatch: expected {expected}, got {actual}"
179            ),
180        }
181    }
182}
183
184impl std::error::Error for PmSettlementModelError {}
185
186/// Validate Phase 1 settlement-pool config invariants.
187pub fn validate_pool_config(config: &PmSettlementPoolConfig) -> Result<(), PmSettlementModelError> {
188    reject_negative(
189        "target_short_oi_notional_multiplier",
190        config.target_short_oi_notional_multiplier,
191    )?;
192    reject_negative("utilization_kink", config.utilization_kink)?;
193    reject_negative("apr_at_kink", config.apr_at_kink)?;
194    reject_negative("max_apr", config.max_apr)?;
195    reject_negative("normal_utilization_cap", config.normal_utilization_cap)?;
196    reject_negative("crisis_utilization_cap", config.crisis_utilization_cap)?;
197
198    if config.bridge_window_ms < 0 {
199        return Err(PmSettlementModelError::InvalidConfig(
200            "bridge_window_ms must be nonnegative",
201        ));
202    }
203    if config.policy_version == 0 {
204        return Err(PmSettlementModelError::InvalidConfig(
205            "policy_version must be nonzero",
206        ));
207    }
208    if !in_unit_interval(config.utilization_kink) {
209        return Err(PmSettlementModelError::InvalidConfig(
210            "utilization_kink must be in 0..=1",
211        ));
212    }
213    if !in_unit_interval(config.normal_utilization_cap) {
214        return Err(PmSettlementModelError::InvalidConfig(
215            "normal_utilization_cap must be in 0..=1",
216        ));
217    }
218    if !in_unit_interval(config.crisis_utilization_cap) {
219        return Err(PmSettlementModelError::InvalidConfig(
220            "crisis_utilization_cap must be in 0..=1",
221        ));
222    }
223    if config.normal_utilization_cap > config.crisis_utilization_cap {
224        return Err(PmSettlementModelError::InvalidConfig(
225            "normal_utilization_cap must not exceed crisis_utilization_cap",
226        ));
227    }
228    if config.utilization_kink == Decimal::ZERO {
229        return Err(PmSettlementModelError::InvalidConfig(
230            "utilization_kink must be positive",
231        ));
232    }
233    if config.max_apr < config.apr_at_kink {
234        return Err(PmSettlementModelError::InvalidConfig(
235            "max_apr must be greater than or equal to apr_at_kink",
236        ));
237    }
238    if config.utilization_kink == dec!(1) && config.max_apr != config.apr_at_kink {
239        return Err(PmSettlementModelError::InvalidConfig(
240            "max_apr must equal apr_at_kink when utilization_kink is 1",
241        ));
242    }
243
244    Ok(())
245}
246
247/// Calculate short-option open interest in USDC.
248///
249/// Long and flat positions are ignored without requiring marks. Short positions
250/// require both spot and option marks so missing financial facts fail fast
251/// instead of silently reducing OI.
252pub fn short_option_oi_usdc(
253    positions: &[PmFixturePosition],
254    marks: &PmMarketMarks,
255) -> Result<Decimal, PmSettlementModelError> {
256    let mut total = Decimal::ZERO;
257
258    for position in positions {
259        if position.quantity >= Decimal::ZERO {
260            continue;
261        }
262
263        let spot = marks
264            .spot_by_underlying
265            .get(&position.key.underlying)
266            .ok_or_else(|| PmSettlementModelError::MissingSpot {
267                underlying: position.key.underlying.clone(),
268            })?;
269        reject_negative("spot", *spot)?;
270
271        let mark = marks.option_mark_by_key.get(&position.key).ok_or_else(|| {
272            PmSettlementModelError::MissingOptionMark {
273                underlying: position.key.underlying.clone(),
274                strike: position.key.strike,
275                expiry_ts_ms: position.key.expiry_ts_ms,
276                option_type: position.key.option_type.clone(),
277            }
278        })?;
279        reject_negative("option_mark", *mark)?;
280
281        total += position.quantity.abs() * *spot;
282    }
283
284    Ok(total)
285}
286
287/// Calculate the target pool size from short OI and active liabilities.
288pub fn pool_target_usdc(
289    short_option_oi_usdc: Decimal,
290    active_timing_bridge_usdc: Decimal,
291    active_settlement_debt_usdc: Decimal,
292    config: &PmSettlementPoolConfig,
293) -> Result<Decimal, PmSettlementModelError> {
294    validate_pool_config(config)?;
295    reject_negative("short_option_oi_usdc", short_option_oi_usdc)?;
296    reject_negative("active_timing_bridge_usdc", active_timing_bridge_usdc)?;
297    reject_negative("active_settlement_debt_usdc", active_settlement_debt_usdc)?;
298
299    let short_oi_target = config.target_short_oi_notional_multiplier * short_option_oi_usdc;
300    let active_liability = active_timing_bridge_usdc + active_settlement_debt_usdc;
301    Ok(short_oi_target.max(active_liability))
302}
303
304/// Calculate total pool capacity as available cash plus active liabilities.
305pub fn pool_capacity_usdc(
306    pool: &PmSettlementPoolSnapshot,
307) -> Result<Decimal, PmSettlementModelError> {
308    validate_pool_snapshot(pool)?;
309    Ok(
310        pool.pool_available_usdc
311            + pool.active_timing_bridge_usdc
312            + pool.active_settlement_debt_usdc,
313    )
314}
315
316/// Calculate active-liability utilization. Zero capacity returns `Ok(None)`.
317pub fn pool_utilization(
318    pool: &PmSettlementPoolSnapshot,
319) -> Result<Option<Decimal>, PmSettlementModelError> {
320    let capacity = pool_capacity_usdc(pool)?;
321    if capacity == Decimal::ZERO {
322        return Ok(None);
323    }
324
325    let utilization =
326        (pool.active_timing_bridge_usdc + pool.active_settlement_debt_usdc) / capacity;
327    validate_utilization(utilization)?;
328    Ok(Some(utilization))
329}
330
331/// Calculate utilization APR using a piecewise linear kink curve.
332///
333/// Utilization must be in `0..=1`. Values up to the kink interpolate from zero
334/// to `apr_at_kink`; values above the kink interpolate to `max_apr` at 100%.
335pub fn utilization_apr(
336    utilization: Decimal,
337    config: &PmSettlementPoolConfig,
338) -> Result<Decimal, PmSettlementModelError> {
339    validate_pool_config(config)?;
340    validate_utilization(utilization)?;
341
342    if utilization <= config.utilization_kink {
343        return Ok(config.apr_at_kink * utilization / config.utilization_kink);
344    }
345
346    if config.utilization_kink == dec!(1) {
347        return Ok(config.apr_at_kink);
348    }
349
350    let post_kink_ratio =
351        (utilization - config.utilization_kink) / (dec!(1) - config.utilization_kink);
352    Ok(config.apr_at_kink + (config.max_apr - config.apr_at_kink) * post_kink_ratio)
353}
354
355/// Classify a settlement liquidity gap without mutating runtime state.
356///
357/// Stale facts return `Unavailable`; zero or fully liquid-covered obligations
358/// return `Paid`; otherwise a PM-healthy account can receive a timing bridge
359/// only when recoverable collateral, pool cash, and post-bridge utilization all
360/// satisfy policy. Policy violations classify as `SettlementDebt`.
361pub fn classify_liquidity_gap(
362    obligation: &PmSettlementObligation,
363    facts: &PmAccountSettlementFacts,
364    pool: &PmSettlementPoolSnapshot,
365    config: &PmSettlementPoolConfig,
366) -> Result<PmLiquidityClassification, PmSettlementModelError> {
367    validate_pool_config(config)?;
368    validate_obligation(obligation)?;
369    validate_facts(facts)?;
370    validate_pool_snapshot(pool)?;
371
372    if pool.policy_version != config.policy_version {
373        return Err(PmSettlementModelError::InvalidConfig(
374            "pool policy_version must match config policy_version",
375        ));
376    }
377    if obligation.wallet != facts.wallet {
378        return Err(PmSettlementModelError::InvalidConfig(
379            "obligation wallet must match account facts wallet",
380        ));
381    }
382    ensure_underlying(&obligation.underlying, &facts.underlying)?;
383    ensure_underlying(&obligation.underlying, &pool.underlying)?;
384
385    if facts.stale {
386        return Ok(PmLiquidityClassification::Unavailable);
387    }
388    if obligation.settlement_obligation_usdc == Decimal::ZERO {
389        return Ok(PmLiquidityClassification::Paid);
390    }
391    if facts.liquid_usdc >= obligation.settlement_obligation_usdc {
392        return Ok(PmLiquidityClassification::Paid);
393    }
394    let Some(current_utilization) = pool_utilization(pool)? else {
395        return Ok(PmLiquidityClassification::Unavailable);
396    };
397    if current_utilization > config.crisis_utilization_cap {
398        return Ok(PmLiquidityClassification::Unavailable);
399    }
400
401    let shortfall = obligation.settlement_obligation_usdc - facts.liquid_usdc;
402    let pool_can_front = pool.pool_available_usdc >= shortfall;
403    if !pool_can_front {
404        return Ok(PmLiquidityClassification::Unavailable);
405    }
406
407    let pm_healthy = facts.pm_equity_usdc >= facts.pm_maintenance_requirement_usdc;
408    let recoverable = facts.recoverable_collateral_usdc >= shortfall;
409    if !(pm_healthy && recoverable) {
410        return Ok(PmLiquidityClassification::SettlementDebt);
411    }
412
413    let capacity = pool_capacity_usdc(pool)?;
414    let projected_utilization =
415        (pool.active_timing_bridge_usdc + pool.active_settlement_debt_usdc + shortfall) / capacity;
416    validate_utilization(projected_utilization)?;
417    let bridge_within_policy = projected_utilization <= config.normal_utilization_cap;
418
419    if bridge_within_policy {
420        Ok(PmLiquidityClassification::TimingBridge)
421    } else {
422        Ok(PmLiquidityClassification::SettlementDebt)
423    }
424}
425
426fn validate_pool_snapshot(pool: &PmSettlementPoolSnapshot) -> Result<(), PmSettlementModelError> {
427    reject_negative("pool_available_usdc", pool.pool_available_usdc)?;
428    reject_negative("pool_target_usdc", pool.pool_target_usdc)?;
429    reject_negative("active_timing_bridge_usdc", pool.active_timing_bridge_usdc)?;
430    reject_negative(
431        "active_settlement_debt_usdc",
432        pool.active_settlement_debt_usdc,
433    )?;
434    if pool.config_version == 0 {
435        return Err(PmSettlementModelError::InvalidConfig(
436            "pool config_version must be nonzero",
437        ));
438    }
439    if pool.policy_version == 0 {
440        return Err(PmSettlementModelError::InvalidConfig(
441            "pool policy_version must be nonzero",
442        ));
443    }
444    Ok(())
445}
446
447fn validate_obligation(obligation: &PmSettlementObligation) -> Result<(), PmSettlementModelError> {
448    reject_negative(
449        "settlement_obligation_usdc",
450        obligation.settlement_obligation_usdc,
451    )?;
452    let expected_obligation = Decimal::ZERO.max(-obligation.net_pnl_usdc);
453    if obligation.settlement_obligation_usdc != expected_obligation {
454        return Err(PmSettlementModelError::InvalidConfig(
455            "settlement_obligation_usdc must equal max(0, -net_pnl_usdc)",
456        ));
457    }
458    Ok(())
459}
460
461fn validate_facts(facts: &PmAccountSettlementFacts) -> Result<(), PmSettlementModelError> {
462    reject_negative("liquid_usdc", facts.liquid_usdc)?;
463    reject_negative(
464        "pm_maintenance_requirement_usdc",
465        facts.pm_maintenance_requirement_usdc,
466    )?;
467    reject_negative(
468        "recoverable_collateral_usdc",
469        facts.recoverable_collateral_usdc,
470    )?;
471    if facts.facts_as_of_ms < 0 {
472        return Err(PmSettlementModelError::InvalidConfig(
473            "facts_as_of_ms must be nonnegative",
474        ));
475    }
476    Ok(())
477}
478
479fn reject_negative(field: &'static str, amount: Decimal) -> Result<(), PmSettlementModelError> {
480    if amount < Decimal::ZERO {
481        Err(PmSettlementModelError::NegativeAmount { field, amount })
482    } else {
483        Ok(())
484    }
485}
486
487fn validate_utilization(utilization: Decimal) -> Result<(), PmSettlementModelError> {
488    if in_unit_interval(utilization) {
489        Ok(())
490    } else {
491        Err(PmSettlementModelError::InvalidUtilization(utilization))
492    }
493}
494
495fn in_unit_interval(value: Decimal) -> bool {
496    value >= Decimal::ZERO && value <= dec!(1)
497}
498
499fn ensure_underlying(expected: &str, actual: &str) -> Result<(), PmSettlementModelError> {
500    if expected == actual {
501        Ok(())
502    } else {
503        Err(PmSettlementModelError::UnderlyingMismatch {
504            expected: expected.to_string(),
505            actual: actual.to_string(),
506        })
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use hypercall_types::WalletAddress;
514
515    fn wallet(byte: u8) -> WalletAddress {
516        WalletAddress::from([byte; 20])
517    }
518
519    fn launch_config() -> PmSettlementPoolConfig {
520        PmSettlementPoolConfig {
521            target_short_oi_notional_multiplier: dec!(0.20),
522            utilization_kink: dec!(0.60),
523            apr_at_kink: dec!(0.04),
524            max_apr: dec!(4.00),
525            normal_utilization_cap: dec!(0.80),
526            crisis_utilization_cap: dec!(0.95),
527            bridge_window_ms: 3_600_000,
528            policy_version: 1,
529        }
530    }
531
532    fn pool(available: Decimal, bridge: Decimal, debt: Decimal) -> PmSettlementPoolSnapshot {
533        PmSettlementPoolSnapshot {
534            underlying: "BTC".to_string(),
535            pool_available_usdc: available,
536            pool_target_usdc: dec!(1_000_000),
537            active_timing_bridge_usdc: bridge,
538            active_settlement_debt_usdc: debt,
539            config_version: 1,
540            policy_version: 1,
541        }
542    }
543
544    fn facts(
545        liquid_usdc: Decimal,
546        pm_equity_usdc: Decimal,
547        maintenance_usdc: Decimal,
548        recoverable_usdc: Decimal,
549    ) -> PmAccountSettlementFacts {
550        PmAccountSettlementFacts {
551            wallet: wallet(1),
552            underlying: "BTC".to_string(),
553            liquid_usdc,
554            pm_equity_usdc,
555            pm_maintenance_requirement_usdc: maintenance_usdc,
556            recoverable_collateral_usdc: recoverable_usdc,
557            facts_as_of_ms: 1_700_000_000_000,
558            stale: false,
559        }
560    }
561
562    fn obligation(net_pnl_usdc: Decimal) -> PmSettlementObligation {
563        PmSettlementObligation {
564            wallet: wallet(1),
565            market_id: "BTC-20260630-100000-C".to_string(),
566            expiry_ts_ms: 1_800_000_000_000,
567            underlying: "BTC".to_string(),
568            net_pnl_usdc,
569            settlement_obligation_usdc: Decimal::ZERO.max(-net_pnl_usdc),
570        }
571    }
572
573    #[test]
574    fn config_validation_rejects_invalid_values() {
575        let mut config = launch_config();
576        config.normal_utilization_cap = dec!(1.10);
577        assert!(validate_pool_config(&config).is_err());
578
579        let mut config = launch_config();
580        config.crisis_utilization_cap = dec!(-0.01);
581        assert!(validate_pool_config(&config).is_err());
582
583        let mut config = launch_config();
584        config.max_apr = dec!(0.01);
585        assert!(validate_pool_config(&config).is_err());
586
587        let mut config = launch_config();
588        config.bridge_window_ms = -1;
589        assert!(validate_pool_config(&config).is_err());
590
591        let mut config = launch_config();
592        config.utilization_kink = dec!(1);
593        config.max_apr = dec!(4);
594        assert!(validate_pool_config(&config).is_err());
595    }
596
597    #[test]
598    fn utilization_apr_matches_launch_curve() {
599        let config = launch_config();
600        let cases = [
601            (dec!(0.20), dec!(0.0133333333333333333333333333)),
602            (dec!(0.40), dec!(0.0266666666666666666666666667)),
603            (dec!(0.60), dec!(0.04)),
604            (dec!(0.80), dec!(2.0200)),
605            (dec!(0.98), dec!(3.8020)),
606        ];
607
608        for (utilization, expected) in cases {
609            assert_eq!(utilization_apr(utilization, &config).unwrap(), expected);
610        }
611    }
612
613    #[test]
614    fn pool_target_is_monotonic() {
615        let config = launch_config();
616        let low = pool_target_usdc(dec!(1_000_000), dec!(50_000), dec!(25_000), &config).unwrap();
617        let higher_oi =
618            pool_target_usdc(dec!(2_000_000), dec!(50_000), dec!(25_000), &config).unwrap();
619        let higher_bridge =
620            pool_target_usdc(dec!(1_000_000), dec!(300_000), dec!(25_000), &config).unwrap();
621        let higher_debt =
622            pool_target_usdc(dec!(1_000_000), dec!(50_000), dec!(300_000), &config).unwrap();
623
624        assert!(higher_oi >= low);
625        assert!(higher_bridge >= low);
626        assert!(higher_debt >= low);
627    }
628
629    #[test]
630    fn zero_capacity_reports_unavailable_utilization() {
631        assert_eq!(
632            pool_utilization(&pool(dec!(0), dec!(0), dec!(0))).unwrap(),
633            None
634        );
635    }
636
637    #[test]
638    fn short_option_oi_requires_marks_and_spot() {
639        let key = PmFixtureOptionKey {
640            underlying: "BTC".to_string(),
641            option_type: OptionType::Call,
642            strike: dec!(100000),
643            expiry_ts_ms: 1_800_000_000_000,
644        };
645        let positions = vec![PmFixturePosition {
646            key: key.clone(),
647            quantity: dec!(-2),
648        }];
649
650        let missing_spot = PmMarketMarks {
651            spot_by_underlying: HashMap::new(),
652            option_mark_by_key: HashMap::from([(key.clone(), dec!(0.10))]),
653        };
654        assert!(matches!(
655            short_option_oi_usdc(&positions, &missing_spot),
656            Err(PmSettlementModelError::MissingSpot { .. })
657        ));
658
659        let missing_mark = PmMarketMarks {
660            spot_by_underlying: HashMap::from([("BTC".to_string(), dec!(100000))]),
661            option_mark_by_key: HashMap::new(),
662        };
663        assert!(matches!(
664            short_option_oi_usdc(&positions, &missing_mark),
665            Err(PmSettlementModelError::MissingOptionMark { .. })
666        ));
667    }
668
669    #[test]
670    fn short_option_oi_uses_only_short_option_exposure() {
671        let short_key = PmFixtureOptionKey {
672            underlying: "BTC".to_string(),
673            option_type: OptionType::Call,
674            strike: dec!(100000),
675            expiry_ts_ms: 1_800_000_000_000,
676        };
677        let long_key = PmFixtureOptionKey {
678            underlying: "BTC".to_string(),
679            option_type: OptionType::Put,
680            strike: dec!(90000),
681            expiry_ts_ms: 1_800_000_000_000,
682        };
683        let positions = vec![
684            PmFixturePosition {
685                key: short_key.clone(),
686                quantity: dec!(-3),
687            },
688            PmFixturePosition {
689                key: long_key.clone(),
690                quantity: dec!(5),
691            },
692        ];
693        let marks = PmMarketMarks {
694            spot_by_underlying: HashMap::from([("BTC".to_string(), dec!(100000))]),
695            option_mark_by_key: HashMap::from([(short_key, dec!(0.12)), (long_key, dec!(0.09))]),
696        };
697
698        assert_eq!(
699            short_option_oi_usdc(&positions, &marks).unwrap(),
700            dec!(300000)
701        );
702    }
703
704    #[test]
705    fn short_option_oi_ignores_missing_marks_for_long_positions() {
706        let short_key = PmFixtureOptionKey {
707            underlying: "BTC".to_string(),
708            option_type: OptionType::Call,
709            strike: dec!(100000),
710            expiry_ts_ms: 1_800_000_000_000,
711        };
712        let long_key = PmFixtureOptionKey {
713            underlying: "BTC".to_string(),
714            option_type: OptionType::Put,
715            strike: dec!(90000),
716            expiry_ts_ms: 1_800_000_000_000,
717        };
718        let positions = vec![
719            PmFixturePosition {
720                key: short_key.clone(),
721                quantity: dec!(-1),
722            },
723            PmFixturePosition {
724                key: long_key,
725                quantity: dec!(5),
726            },
727        ];
728        let marks = PmMarketMarks {
729            spot_by_underlying: HashMap::from([("BTC".to_string(), dec!(100000))]),
730            option_mark_by_key: HashMap::from([(short_key, dec!(0.12))]),
731        };
732
733        assert_eq!(
734            short_option_oi_usdc(&positions, &marks).unwrap(),
735            dec!(100000)
736        );
737    }
738
739    #[test]
740    fn classifier_covers_paid_bridge_debt_and_unavailable() {
741        let config = launch_config();
742
743        assert_eq!(
744            classify_liquidity_gap(
745                &obligation(dec!(-100)),
746                &facts(dec!(100), dec!(1_000), dec!(500), dec!(0)),
747                &pool(dec!(10_000), dec!(1), dec!(0)),
748                &config
749            )
750            .unwrap(),
751            PmLiquidityClassification::Paid
752        );
753
754        assert_eq!(
755            classify_liquidity_gap(
756                &obligation(dec!(-1_000)),
757                &facts(dec!(100), dec!(10_000), dec!(5_000), dec!(1_000)),
758                &pool(dec!(10_000), dec!(1), dec!(0)),
759                &config
760            )
761            .unwrap(),
762            PmLiquidityClassification::TimingBridge
763        );
764
765        assert_eq!(
766            classify_liquidity_gap(
767                &obligation(dec!(-1_000)),
768                &facts(dec!(100), dec!(4_000), dec!(5_000), dec!(1_000)),
769                &pool(dec!(10_000), dec!(1), dec!(0)),
770                &config
771            )
772            .unwrap(),
773            PmLiquidityClassification::SettlementDebt
774        );
775
776        let mut stale_facts = facts(dec!(100), dec!(10_000), dec!(5_000), dec!(1_000));
777        stale_facts.stale = true;
778        assert_eq!(
779            classify_liquidity_gap(
780                &obligation(dec!(-1_000)),
781                &stale_facts,
782                &pool(dec!(10_000), dec!(1), dec!(0)),
783                &config
784            )
785            .unwrap(),
786            PmLiquidityClassification::Unavailable
787        );
788    }
789
790    #[test]
791    fn classifier_blocks_bridges_that_exceed_utilization_policy() {
792        let config = launch_config();
793
794        assert_eq!(
795            classify_liquidity_gap(
796                &obligation(dec!(-100)),
797                &facts(dec!(0), dec!(10_000), dec!(5_000), dec!(100)),
798                &pool(dec!(210), dec!(790), dec!(0)),
799                &config
800            )
801            .unwrap(),
802            PmLiquidityClassification::SettlementDebt
803        );
804    }
805
806    #[test]
807    fn classifier_reports_unavailable_when_pool_exceeds_crisis_cap() {
808        let config = launch_config();
809
810        assert_eq!(
811            classify_liquidity_gap(
812                &obligation(dec!(-10)),
813                &facts(dec!(0), dec!(10_000), dec!(5_000), dec!(10)),
814                &pool(dec!(40), dec!(960), dec!(0)),
815                &config
816            )
817            .unwrap(),
818            PmLiquidityClassification::Unavailable
819        );
820    }
821
822    #[test]
823    fn classifier_reports_unavailable_when_shortfall_exceeds_available_pool_cash() {
824        let config = launch_config();
825
826        assert_eq!(
827            classify_liquidity_gap(
828                &obligation(dec!(-200)),
829                &facts(dec!(0), dec!(10_000), dec!(5_000), dec!(200)),
830                &pool(dec!(100), dec!(900), dec!(0)),
831                &config
832            )
833            .unwrap(),
834            PmLiquidityClassification::Unavailable
835        );
836    }
837
838    #[test]
839    fn classifier_rejects_policy_version_mismatch() {
840        let config = launch_config();
841        let mut pool = pool(dec!(10_000), dec!(1), dec!(0));
842        pool.policy_version = config.policy_version + 1;
843
844        assert!(matches!(
845            classify_liquidity_gap(
846                &obligation(dec!(-1_000)),
847                &facts(dec!(100), dec!(10_000), dec!(5_000), dec!(1_000)),
848                &pool,
849                &config
850            ),
851            Err(PmSettlementModelError::InvalidConfig(
852                "pool policy_version must match config policy_version"
853            ))
854        ));
855    }
856
857    #[test]
858    fn deterministic_fixture_matrix_matches_expected_classifications() {
859        let config = launch_config();
860        for fixture in fixtures() {
861            assert_eq!(
862                short_option_oi_usdc(&fixture.positions, &fixture.marks).unwrap(),
863                fixture.expected_short_option_oi_usdc,
864                "{} short OI",
865                fixture.name
866            );
867
868            let facts = PmAccountSettlementFacts {
869                wallet: fixture.wallet,
870                underlying: fixture.underlying.clone(),
871                liquid_usdc: fixture.cash_usdc,
872                pm_equity_usdc: fixture.expected_pm_equity_usdc,
873                pm_maintenance_requirement_usdc: dec!(5_000),
874                recoverable_collateral_usdc: fixture.expected_recoverable_collateral_usdc,
875                facts_as_of_ms: 1_700_000_000_000,
876                stale: false,
877            };
878            let obligation = PmSettlementObligation {
879                wallet: fixture.wallet,
880                market_id: format!("{}-fixture", fixture.name),
881                expiry_ts_ms: 1_800_000_000_000,
882                underlying: fixture.underlying.clone(),
883                net_pnl_usdc: -fixture.settlement_obligation_usdc,
884                settlement_obligation_usdc: fixture.settlement_obligation_usdc,
885            };
886            let pool = pool(dec!(100_000), dec!(1), dec!(0));
887
888            assert_eq!(
889                classify_liquidity_gap(&obligation, &facts, &pool, &config).unwrap(),
890                fixture.expected_classification,
891                "{} classification",
892                fixture.name
893            );
894        }
895    }
896
897    fn fixtures() -> Vec<PmSettlementFixture> {
898        vec![
899            fixture(FixtureSpec {
900                name: "delta-hedged short option",
901                positions: vec![position(
902                    "BTC",
903                    OptionType::Call,
904                    dec!(100000),
905                    dec!(-2),
906                    dec!(0.10),
907                )],
908                cash_usdc: dec!(500),
909                expected_pm_equity_usdc: dec!(15_000),
910                expected_recoverable_collateral_usdc: dec!(2_500),
911                settlement_obligation_usdc: dec!(2_000),
912                expected_short_option_oi_usdc: dec!(200_000),
913                expected_classification: PmLiquidityClassification::TimingBridge,
914            }),
915            fixture(FixtureSpec {
916                name: "calendar spread",
917                positions: vec![
918                    position("BTC", OptionType::Call, dec!(100000), dec!(-1), dec!(0.12)),
919                    position("BTC", OptionType::Call, dec!(105000), dec!(1), dec!(0.08)),
920                ],
921                cash_usdc: dec!(100),
922                expected_pm_equity_usdc: dec!(12_000),
923                expected_recoverable_collateral_usdc: dec!(2_000),
924                settlement_obligation_usdc: dec!(1_500),
925                expected_short_option_oi_usdc: dec!(100_000),
926                expected_classification: PmLiquidityClassification::TimingBridge,
927            }),
928            fixture(FixtureSpec {
929                name: "synthetic long exposure",
930                positions: vec![
931                    position("BTC", OptionType::Call, dec!(90000), dec!(2), dec!(0.20)),
932                    position("BTC", OptionType::Put, dec!(90000), dec!(-2), dec!(0.05)),
933                ],
934                cash_usdc: dec!(3_000),
935                expected_pm_equity_usdc: dec!(20_000),
936                expected_recoverable_collateral_usdc: dec!(7_000),
937                settlement_obligation_usdc: dec!(0),
938                expected_short_option_oi_usdc: dec!(200_000),
939                expected_classification: PmLiquidityClassification::Paid,
940            }),
941            fixture(FixtureSpec {
942                name: "vol-seller strangle",
943                positions: vec![
944                    position("BTC", OptionType::Call, dec!(115000), dec!(-2), dec!(0.07)),
945                    position("BTC", OptionType::Put, dec!(85000), dec!(-2), dec!(0.06)),
946                ],
947                cash_usdc: dec!(250),
948                expected_pm_equity_usdc: dec!(25_000),
949                expected_recoverable_collateral_usdc: dec!(4_000),
950                settlement_obligation_usdc: dec!(3_000),
951                expected_short_option_oi_usdc: dec!(400_000),
952                expected_classification: PmLiquidityClassification::TimingBridge,
953            }),
954            fixture(FixtureSpec {
955                name: "PM-insufficient account",
956                positions: vec![position(
957                    "BTC",
958                    OptionType::Put,
959                    dec!(95000),
960                    dec!(-2),
961                    dec!(0.11),
962                )],
963                cash_usdc: dec!(100),
964                expected_pm_equity_usdc: dec!(3_000),
965                expected_recoverable_collateral_usdc: dec!(100),
966                settlement_obligation_usdc: dec!(4_000),
967                expected_short_option_oi_usdc: dec!(200_000),
968                expected_classification: PmLiquidityClassification::SettlementDebt,
969            }),
970        ]
971    }
972
973    struct FixtureSpec {
974        name: &'static str,
975        positions: Vec<(PmFixturePosition, Decimal)>,
976        cash_usdc: Decimal,
977        expected_pm_equity_usdc: Decimal,
978        expected_recoverable_collateral_usdc: Decimal,
979        settlement_obligation_usdc: Decimal,
980        expected_short_option_oi_usdc: Decimal,
981        expected_classification: PmLiquidityClassification,
982    }
983
984    fn fixture(spec: FixtureSpec) -> PmSettlementFixture {
985        let mut option_mark_by_key = HashMap::new();
986        let positions = spec
987            .positions
988            .into_iter()
989            .map(|(position, mark)| {
990                option_mark_by_key.insert(position.key.clone(), mark);
991                position
992            })
993            .collect();
994
995        PmSettlementFixture {
996            name: spec.name,
997            wallet: wallet(1),
998            underlying: "BTC".to_string(),
999            positions,
1000            marks: PmMarketMarks {
1001                spot_by_underlying: HashMap::from([("BTC".to_string(), dec!(100000))]),
1002                option_mark_by_key,
1003            },
1004            cash_usdc: spec.cash_usdc,
1005            expected_short_option_oi_usdc: spec.expected_short_option_oi_usdc,
1006            expected_pm_equity_usdc: spec.expected_pm_equity_usdc,
1007            expected_recoverable_collateral_usdc: spec.expected_recoverable_collateral_usdc,
1008            settlement_obligation_usdc: spec.settlement_obligation_usdc,
1009            expected_classification: spec.expected_classification,
1010        }
1011    }
1012
1013    fn position(
1014        underlying: &str,
1015        option_type: OptionType,
1016        strike: Decimal,
1017        quantity: Decimal,
1018        mark: Decimal,
1019    ) -> (PmFixturePosition, Decimal) {
1020        (
1021            PmFixturePosition {
1022                key: PmFixtureOptionKey {
1023                    underlying: underlying.to_string(),
1024                    option_type,
1025                    strike,
1026                    expiry_ts_ms: 1_800_000_000_000,
1027                },
1028                quantity,
1029            },
1030            mark,
1031        )
1032    }
1033}