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)]
9pub struct PmSettlementPoolConfig {
15 pub target_short_oi_notional_multiplier: Decimal,
17 pub utilization_kink: Decimal,
19 pub apr_at_kink: Decimal,
21 pub max_apr: Decimal,
23 pub normal_utilization_cap: Decimal,
25 pub crisis_utilization_cap: Decimal,
27 pub bridge_window_ms: i64,
29 pub policy_version: u32,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub 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)]
50pub 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)]
66pub 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)]
79pub enum PmLiquidityClassification {
81 Paid,
83 TimingBridge,
85 SettlementDebt,
87 Unavailable,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
92pub 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)]
101pub struct PmFixturePosition {
103 pub key: PmFixtureOptionKey,
104 pub quantity: Decimal,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub 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)]
115pub 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)]
131pub 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
186pub 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
247pub 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
287pub 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
304pub 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
316pub 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
331pub 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
355pub 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}