Skip to main content

hypercall_margin/portfolio/
recovery_planner.rs

1use hypercall_types::{Side, WalletAddress};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use sha3::{Digest, Keccak256};
5
6pub const RECOVERY_PRIORITY_VERSION: u32 = 1;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum PmRecoveryTrigger {
10    Debt,
11    OverdueBridge,
12    MaintenanceBreach,
13    UtilizationBreach,
14    CrisisCap,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PmRecoveryReason {
19    SettlementDebt,
20    TimingBridge,
21    Maintenance,
22    Utilization,
23    CrisisCap,
24    StaleMarketState,
25    NoActionableRecovery,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct PmRecoveryPlannerInput {
30    pub wallet: WalletAddress,
31    pub underlying: String,
32    pub trigger: PmRecoveryTrigger,
33    pub reason: PmRecoveryReason,
34    pub trigger_event_id: String,
35    pub trigger_sequence: u64,
36    pub policy_version: u32,
37    pub liability_shape: String,
38    pub target_reduction_usdc: Decimal,
39    pub pool_capacity_usdc: Decimal,
40    pub active_liability_usdc: Decimal,
41    pub market_state_stale: bool,
42    pub bridge_deadline_ms: Option<i64>,
43    pub now_ms: i64,
44    pub open_orders: Vec<PmRecoveryOpenOrder>,
45    pub perps: Vec<PmRecoveryPerpPosition>,
46    pub options: Vec<PmRecoveryOptionPosition>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct PmRecoveryOpenOrder {
51    pub order_id: u64,
52    pub target_key: String,
53    pub risk_increasing: bool,
54    pub capacity_usdc: Decimal,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct PmRecoveryPerpPosition {
59    pub asset: String,
60    pub size: Decimal,
61    pub unrealized_pnl_usdc: Decimal,
62    pub market_impact_usdc: Decimal,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PmRecoveryOptionPosition {
67    pub market_id: String,
68    pub size: Decimal,
69    pub side_to_close: Side,
70    pub expiry_ts_ms: i64,
71    pub itm: bool,
72    pub obligation_reduction_usdc: Decimal,
73    pub timing_bridge_usdc: Decimal,
74    pub hedge_breakage_usdc: Decimal,
75    pub market_impact_usdc: Decimal,
76    pub later_dated_hedge: bool,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct PmRecoveryPlan {
81    pub plan_id: String,
82    pub wallet: WalletAddress,
83    pub underlying: String,
84    pub trigger: PmRecoveryTrigger,
85    pub reason: PmRecoveryReason,
86    pub policy_version: u32,
87    pub recovery_priority_version: u32,
88    pub input_digest: String,
89    pub target_reduction_usdc: Decimal,
90    pub expected_usdc_recovered: Decimal,
91    pub expected_obligation_reduced: Decimal,
92    pub expected_impact_usdc: Decimal,
93    pub post_plan_utilization: Option<Decimal>,
94    pub actions: Vec<PmRecoveryAction>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum PmRecoveryAction {
99    CancelOrder {
100        order_id: u64,
101        reason: PmRecoveryReason,
102    },
103    ClosePerp {
104        asset: String,
105        size: Decimal,
106        reduce_only: bool,
107    },
108    CloseOption {
109        market_id: String,
110        size: Decimal,
111        side: Side,
112    },
113    TransferCollateral {
114        source: String,
115        amount_usdc: Decimal,
116    },
117    EscalateManual {
118        reason: PmRecoveryReason,
119    },
120}
121
122pub fn plan_pm_recovery(input: PmRecoveryPlannerInput) -> PmRecoveryPlan {
123    let input_digest = input_digest(&input);
124    let mut candidates = recovery_candidates(&input);
125    candidates.sort_by(compare_candidates);
126
127    let actions = if input.market_state_stale {
128        vec![PlannedCandidate::manual(PmRecoveryReason::StaleMarketState)]
129    } else if candidates.is_empty() {
130        vec![PlannedCandidate::manual(
131            PmRecoveryReason::NoActionableRecovery,
132        )]
133    } else {
134        candidates
135    };
136
137    let expected_usdc_recovered = actions.iter().fold(Decimal::ZERO, |acc, action| {
138        acc + action.expected_usdc_recovered
139    });
140    let expected_obligation_reduced = actions.iter().fold(Decimal::ZERO, |acc, action| {
141        acc + action.expected_obligation_reduced
142    });
143    let expected_impact_usdc = actions.iter().fold(Decimal::ZERO, |acc, action| {
144        acc + action.expected_impact_usdc
145    });
146    let projected_active_liability =
147        (input.active_liability_usdc - expected_obligation_reduced).max(Decimal::ZERO);
148    let post_plan_utilization = if input.pool_capacity_usdc > Decimal::ZERO {
149        Some(projected_active_liability / input.pool_capacity_usdc)
150    } else {
151        None
152    };
153
154    PmRecoveryPlan {
155        plan_id: plan_id(&input, &input_digest),
156        wallet: input.wallet,
157        underlying: input.underlying,
158        trigger: input.trigger,
159        reason: input.reason,
160        policy_version: input.policy_version,
161        recovery_priority_version: RECOVERY_PRIORITY_VERSION,
162        input_digest,
163        target_reduction_usdc: input.target_reduction_usdc,
164        expected_usdc_recovered,
165        expected_obligation_reduced,
166        expected_impact_usdc,
167        post_plan_utilization,
168        actions: actions
169            .into_iter()
170            .map(|candidate| candidate.action)
171            .collect(),
172    }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176struct PlannedCandidate {
177    priority: u8,
178    expected_usdc_recovered: Decimal,
179    expected_obligation_reduced: Decimal,
180    expected_impact_usdc: Decimal,
181    bridge_deadline_ms: Option<i64>,
182    target_key: String,
183    original_id: String,
184    action: PmRecoveryAction,
185}
186
187impl PlannedCandidate {
188    fn manual(reason: PmRecoveryReason) -> Self {
189        Self {
190            priority: 99,
191            expected_usdc_recovered: Decimal::ZERO,
192            expected_obligation_reduced: Decimal::ZERO,
193            expected_impact_usdc: Decimal::ZERO,
194            bridge_deadline_ms: None,
195            target_key: format!("manual:{reason:?}"),
196            original_id: "manual".to_string(),
197            action: PmRecoveryAction::EscalateManual { reason },
198        }
199    }
200}
201
202fn recovery_candidates(input: &PmRecoveryPlannerInput) -> Vec<PlannedCandidate> {
203    if input.market_state_stale {
204        return Vec::new();
205    }
206    let mut candidates = Vec::new();
207
208    let mut open_orders = input.open_orders.clone();
209    open_orders.sort_by(|left, right| {
210        left.target_key
211            .cmp(&right.target_key)
212            .then_with(|| left.order_id.cmp(&right.order_id))
213    });
214    for order in open_orders
215        .into_iter()
216        .filter(|order| order.risk_increasing)
217    {
218        candidates.push(PlannedCandidate {
219            priority: 0,
220            expected_usdc_recovered: Decimal::ZERO,
221            expected_obligation_reduced: order.capacity_usdc,
222            expected_impact_usdc: Decimal::ZERO,
223            bridge_deadline_ms: input.bridge_deadline_ms,
224            target_key: order.target_key,
225            original_id: order.order_id.to_string(),
226            action: PmRecoveryAction::CancelOrder {
227                order_id: order.order_id,
228                reason: input.reason,
229            },
230        });
231    }
232
233    let mut perps = input.perps.clone();
234    perps.sort_by(|left, right| left.asset.cmp(&right.asset));
235    for perp in perps
236        .into_iter()
237        .filter(|perp| perp.unrealized_pnl_usdc > Decimal::ZERO && perp.size != Decimal::ZERO)
238    {
239        candidates.push(PlannedCandidate {
240            priority: 1,
241            expected_usdc_recovered: perp.unrealized_pnl_usdc,
242            expected_obligation_reduced: perp.unrealized_pnl_usdc,
243            expected_impact_usdc: perp.market_impact_usdc,
244            bridge_deadline_ms: input.bridge_deadline_ms,
245            target_key: format!("perp:{}", perp.asset),
246            original_id: perp.asset.clone(),
247            action: PmRecoveryAction::ClosePerp {
248                asset: perp.asset,
249                size: perp.size.abs(),
250                reduce_only: true,
251            },
252        });
253    }
254
255    let bridge_is_aged = input
256        .bridge_deadline_ms
257        .is_some_and(|deadline| input.now_ms >= deadline);
258    let mut options = input.options.clone();
259    options.sort_by(|left, right| {
260        left.expiry_ts_ms
261            .cmp(&right.expiry_ts_ms)
262            .then_with(|| left.market_id.cmp(&right.market_id))
263    });
264    for option in options
265        .into_iter()
266        .filter(|option| option.size > Decimal::ZERO)
267    {
268        if option.later_dated_hedge && !bridge_is_aged {
269            continue;
270        }
271        let option_class = OptionRecoveryClass::classify(&option);
272        candidates.push(PlannedCandidate {
273            priority: option_class.priority(),
274            expected_usdc_recovered: Decimal::ZERO,
275            expected_obligation_reduced: option.obligation_reduction_usdc
276                + option.timing_bridge_usdc,
277            expected_impact_usdc: option.market_impact_usdc + option.hedge_breakage_usdc,
278            bridge_deadline_ms: input.bridge_deadline_ms,
279            target_key: format!("option:{}", option.market_id),
280            original_id: option.market_id.clone(),
281            action: PmRecoveryAction::CloseOption {
282                market_id: option.market_id,
283                size: option.size,
284                side: option.side_to_close,
285            },
286        });
287    }
288
289    candidates
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum OptionRecoveryClass {
294    NearTermItmShort,
295    TimingBridgeDriver,
296    AgedCalendarHedge,
297    ResidualRiskReduction,
298}
299
300impl OptionRecoveryClass {
301    fn classify(option: &PmRecoveryOptionPosition) -> Self {
302        match (
303            option.itm,
304            option.timing_bridge_usdc > Decimal::ZERO,
305            option.later_dated_hedge,
306        ) {
307            (true, _, _) => Self::NearTermItmShort,
308            (false, true, _) => Self::TimingBridgeDriver,
309            (false, false, true) => Self::AgedCalendarHedge,
310            (false, false, false) => Self::ResidualRiskReduction,
311        }
312    }
313
314    fn priority(self) -> u8 {
315        match self {
316            Self::NearTermItmShort => 2,
317            Self::TimingBridgeDriver => 3,
318            Self::AgedCalendarHedge => 4,
319            Self::ResidualRiskReduction => 5,
320        }
321    }
322}
323
324fn compare_candidates(left: &PlannedCandidate, right: &PlannedCandidate) -> std::cmp::Ordering {
325    left.priority
326        .cmp(&right.priority)
327        .then_with(|| {
328            right
329                .expected_usdc_recovered
330                .cmp(&left.expected_usdc_recovered)
331        })
332        .then_with(|| {
333            right
334                .expected_obligation_reduced
335                .cmp(&left.expected_obligation_reduced)
336        })
337        .then_with(|| left.expected_impact_usdc.cmp(&right.expected_impact_usdc))
338        .then_with(|| left.bridge_deadline_ms.cmp(&right.bridge_deadline_ms))
339        .then_with(|| left.target_key.cmp(&right.target_key))
340        .then_with(|| left.original_id.cmp(&right.original_id))
341}
342
343fn input_digest(input: &PmRecoveryPlannerInput) -> String {
344    let mut h = Keccak256::new();
345    h.update(input.wallet.as_bytes());
346    update_len_prefixed(&mut h, &input.underlying);
347    h.update([input.trigger as u8]);
348    h.update([input.reason as u8]);
349    update_len_prefixed(&mut h, &input.trigger_event_id);
350    h.update(input.trigger_sequence.to_le_bytes());
351    h.update(input.policy_version.to_le_bytes());
352    update_len_prefixed(&mut h, &input.liability_shape);
353    h.update(input.target_reduction_usdc.serialize());
354    h.update(input.pool_capacity_usdc.serialize());
355    h.update(input.active_liability_usdc.serialize());
356    h.update([input.market_state_stale as u8]);
357    h.update([input.bridge_deadline_ms.is_some() as u8]);
358    h.update(input.bridge_deadline_ms.unwrap_or_default().to_le_bytes());
359    h.update(input.now_ms.to_le_bytes());
360    for order in sorted_orders(&input.open_orders) {
361        h.update(order.order_id.to_le_bytes());
362        update_len_prefixed(&mut h, &order.target_key);
363        h.update([order.risk_increasing as u8]);
364        h.update(order.capacity_usdc.serialize());
365    }
366    for perp in sorted_perps(&input.perps) {
367        update_len_prefixed(&mut h, &perp.asset);
368        h.update(perp.size.serialize());
369        h.update(perp.unrealized_pnl_usdc.serialize());
370        h.update(perp.market_impact_usdc.serialize());
371    }
372    for option in sorted_options(&input.options) {
373        update_len_prefixed(&mut h, &option.market_id);
374        h.update(option.size.serialize());
375        update_len_prefixed(&mut h, side_hash_key(option.side_to_close));
376        h.update(option.expiry_ts_ms.to_le_bytes());
377        h.update([option.itm as u8]);
378        h.update(option.obligation_reduction_usdc.serialize());
379        h.update(option.timing_bridge_usdc.serialize());
380        h.update(option.hedge_breakage_usdc.serialize());
381        h.update(option.market_impact_usdc.serialize());
382        h.update([option.later_dated_hedge as u8]);
383    }
384    format!("0x{}", hex::encode(h.finalize()))
385}
386
387fn side_hash_key(side: Side) -> &'static str {
388    match side {
389        Side::Buy => "Buy",
390        Side::Sell => "Sell",
391    }
392}
393
394fn plan_id(input: &PmRecoveryPlannerInput, input_digest: &str) -> String {
395    let mut h = Keccak256::new();
396    h.update(input.wallet.as_bytes());
397    update_len_prefixed(&mut h, &input.underlying);
398    update_len_prefixed(&mut h, &input.trigger_event_id);
399    h.update([input.reason as u8]);
400    h.update(input.policy_version.to_le_bytes());
401    update_len_prefixed(&mut h, &input.liability_shape);
402    h.update(input.trigger_sequence.to_le_bytes());
403    update_len_prefixed(&mut h, input_digest);
404    format!("pmr-{}", hex::encode(h.finalize()))
405}
406
407fn sorted_orders(orders: &[PmRecoveryOpenOrder]) -> Vec<&PmRecoveryOpenOrder> {
408    let mut sorted: Vec<_> = orders.iter().collect();
409    sorted.sort_by(|left, right| {
410        left.target_key
411            .cmp(&right.target_key)
412            .then_with(|| left.order_id.cmp(&right.order_id))
413    });
414    sorted
415}
416
417fn sorted_perps(perps: &[PmRecoveryPerpPosition]) -> Vec<&PmRecoveryPerpPosition> {
418    let mut sorted: Vec<_> = perps.iter().collect();
419    sorted.sort_by(|left, right| left.asset.cmp(&right.asset));
420    sorted
421}
422
423fn sorted_options(options: &[PmRecoveryOptionPosition]) -> Vec<&PmRecoveryOptionPosition> {
424    let mut sorted: Vec<_> = options.iter().collect();
425    sorted.sort_by(|left, right| {
426        left.expiry_ts_ms
427            .cmp(&right.expiry_ts_ms)
428            .then_with(|| left.market_id.cmp(&right.market_id))
429    });
430    sorted
431}
432
433fn update_len_prefixed<D: Digest>(h: &mut D, value: &str) {
434    h.update((value.len() as u64).to_le_bytes());
435    h.update(value.as_bytes());
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use std::str::FromStr;
442
443    fn wallet() -> WalletAddress {
444        WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap()
445    }
446
447    fn base_input() -> PmRecoveryPlannerInput {
448        PmRecoveryPlannerInput {
449            wallet: wallet(),
450            underlying: "BTC".to_string(),
451            trigger: PmRecoveryTrigger::Debt,
452            reason: PmRecoveryReason::SettlementDebt,
453            trigger_event_id: "event-1".to_string(),
454            trigger_sequence: 7,
455            policy_version: 1,
456            liability_shape: "debt:1000".to_string(),
457            target_reduction_usdc: Decimal::from(1000),
458            pool_capacity_usdc: Decimal::from(10_000),
459            active_liability_usdc: Decimal::from(2_000),
460            market_state_stale: false,
461            bridge_deadline_ms: Some(2_000),
462            now_ms: 1_500,
463            open_orders: vec![],
464            perps: vec![],
465            options: vec![],
466        }
467    }
468
469    #[test]
470    fn identical_inputs_produce_identical_plan_id_and_order() {
471        let mut input = base_input();
472        input.open_orders = vec![
473            PmRecoveryOpenOrder {
474                order_id: 2,
475                target_key: "b".to_string(),
476                risk_increasing: true,
477                capacity_usdc: Decimal::from(10),
478            },
479            PmRecoveryOpenOrder {
480                order_id: 1,
481                target_key: "a".to_string(),
482                risk_increasing: true,
483                capacity_usdc: Decimal::from(10),
484            },
485        ];
486        let mut reordered = input.clone();
487        reordered.open_orders.reverse();
488
489        let first = plan_pm_recovery(input);
490        let second = plan_pm_recovery(reordered);
491
492        assert_eq!(first.plan_id, second.plan_id);
493        assert_eq!(first.input_digest, second.input_digest);
494        assert_eq!(first.actions, second.actions);
495        assert!(matches!(
496            first.actions[0],
497            PmRecoveryAction::CancelOrder { order_id: 1, .. }
498        ));
499    }
500
501    #[test]
502    fn bridge_deadline_presence_changes_digest() {
503        let mut none = base_input();
504        none.bridge_deadline_ms = None;
505        let mut zero = none.clone();
506        zero.bridge_deadline_ms = Some(0);
507
508        let none_plan = plan_pm_recovery(none);
509        let zero_plan = plan_pm_recovery(zero);
510
511        assert_ne!(none_plan.input_digest, zero_plan.input_digest);
512        assert_ne!(none_plan.plan_id, zero_plan.plan_id);
513    }
514
515    #[test]
516    fn profitable_perp_ranks_before_unrelated_option() {
517        let mut input = base_input();
518        input.perps = vec![PmRecoveryPerpPosition {
519            asset: "BTC".to_string(),
520            size: Decimal::from(1),
521            unrealized_pnl_usdc: Decimal::from(500),
522            market_impact_usdc: Decimal::from(2),
523        }];
524        input.options = vec![PmRecoveryOptionPosition {
525            market_id: "BTC-20260630-100000-C".to_string(),
526            size: Decimal::from(1),
527            side_to_close: Side::Buy,
528            expiry_ts_ms: 1_780_000_000_000,
529            itm: true,
530            obligation_reduction_usdc: Decimal::from(700),
531            timing_bridge_usdc: Decimal::ZERO,
532            hedge_breakage_usdc: Decimal::ZERO,
533            market_impact_usdc: Decimal::from(1),
534            later_dated_hedge: false,
535        }];
536
537        let plan = plan_pm_recovery(input);
538
539        assert!(matches!(
540            plan.actions[0],
541            PmRecoveryAction::ClosePerp { .. }
542        ));
543    }
544
545    #[test]
546    fn stale_market_state_escalates_without_actions() {
547        let mut input = base_input();
548        input.market_state_stale = true;
549        input.perps = vec![PmRecoveryPerpPosition {
550            asset: "BTC".to_string(),
551            size: Decimal::from(1),
552            unrealized_pnl_usdc: Decimal::from(500),
553            market_impact_usdc: Decimal::from(2),
554        }];
555
556        let plan = plan_pm_recovery(input);
557
558        assert_eq!(
559            plan.actions,
560            vec![PmRecoveryAction::EscalateManual {
561                reason: PmRecoveryReason::StaleMarketState
562            }]
563        );
564    }
565
566    #[test]
567    fn later_dated_hedge_waits_until_bridge_deadline() {
568        let mut input = base_input();
569        input.options = vec![PmRecoveryOptionPosition {
570            market_id: "BTC-20260730-100000-C".to_string(),
571            size: Decimal::from(1),
572            side_to_close: Side::Buy,
573            expiry_ts_ms: 1_782_000_000_000,
574            itm: false,
575            obligation_reduction_usdc: Decimal::ZERO,
576            timing_bridge_usdc: Decimal::from(400),
577            hedge_breakage_usdc: Decimal::from(20),
578            market_impact_usdc: Decimal::from(1),
579            later_dated_hedge: true,
580        }];
581
582        let early = plan_pm_recovery(input.clone());
583        input.now_ms = 2_000;
584        let aged = plan_pm_recovery(input);
585
586        assert!(matches!(
587            early.actions[0],
588            PmRecoveryAction::EscalateManual {
589                reason: PmRecoveryReason::NoActionableRecovery
590            }
591        ));
592        assert!(matches!(
593            aged.actions[0],
594            PmRecoveryAction::CloseOption { .. }
595        ));
596    }
597
598    #[test]
599    fn option_recovery_ranks_larger_liability_reduction_before_lower_impact() {
600        let mut input = base_input();
601        input.options = vec![
602            PmRecoveryOptionPosition {
603                market_id: "BTC-small-bridge".to_string(),
604                size: Decimal::from(1),
605                side_to_close: Side::Buy,
606                expiry_ts_ms: 1_780_000_000_000,
607                itm: false,
608                obligation_reduction_usdc: Decimal::ZERO,
609                timing_bridge_usdc: Decimal::from(50),
610                hedge_breakage_usdc: Decimal::ZERO,
611                market_impact_usdc: Decimal::from(1),
612                later_dated_hedge: false,
613            },
614            PmRecoveryOptionPosition {
615                market_id: "BTC-large-bridge".to_string(),
616                size: Decimal::from(1),
617                side_to_close: Side::Buy,
618                expiry_ts_ms: 1_780_000_000_001,
619                itm: false,
620                obligation_reduction_usdc: Decimal::ZERO,
621                timing_bridge_usdc: Decimal::from(500),
622                hedge_breakage_usdc: Decimal::ZERO,
623                market_impact_usdc: Decimal::from(2),
624                later_dated_hedge: false,
625            },
626        ];
627
628        let plan = plan_pm_recovery(input);
629
630        assert!(matches!(
631            &plan.actions[0],
632            PmRecoveryAction::CloseOption { market_id, .. }
633                if market_id == "BTC-large-bridge"
634        ));
635    }
636}