Skip to main content

hypercall_types/
position_metrics.rs

1use crate::api_models::{Portfolio, PositionWithMetrics};
2use crate::utils::ParsedSymbol;
3use crate::MarginMode;
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use std::collections::HashMap;
7use std::fmt;
8
9const MIN_LIQUIDATION_PRICE: Decimal = dec!(0);
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct PositionMetricsError;
13
14impl fmt::Display for PositionMetricsError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        f.write_str("position metrics unavailable")
17    }
18}
19
20impl std::error::Error for PositionMetricsError {}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct PositionMarginMetrics {
24    pub initial_margin: Decimal,
25    pub maintenance_margin: Decimal,
26}
27
28pub fn compute_short_option_liquidation_mark(
29    equity: Decimal,
30    maintenance_margin_required: Decimal,
31    position_size: Decimal,
32    current_mark: Decimal,
33) -> Result<Decimal, PositionMetricsError> {
34    if position_size >= dec!(0) {
35        tracing::error!(
36            "Refusing liquidation price computation for non-short position: size={}",
37            position_size
38        );
39        return Err(PositionMetricsError);
40    }
41
42    if equity <= maintenance_margin_required {
43        return Ok(current_mark.max(MIN_LIQUIDATION_PRICE));
44    }
45
46    let liq_mark = current_mark + (maintenance_margin_required - equity) / position_size;
47    Ok(liq_mark.max(MIN_LIQUIDATION_PRICE))
48}
49
50fn derive_position_mark(position: &PositionWithMetrics) -> Result<Decimal, PositionMetricsError> {
51    let amount = position.position.amount;
52    if amount == dec!(0) {
53        tracing::error!(
54            "Zero-size position encountered while deriving mark for {}",
55            position.position.symbol
56        );
57        return Err(PositionMetricsError);
58    }
59    Ok(position.position.entry_price + position.position.unrealized_pnl / amount)
60}
61
62fn update_current_notional_value(
63    position: &mut PositionWithMetrics,
64) -> Result<(), PositionMetricsError> {
65    let amount = position.position.amount;
66    if amount == dec!(0) {
67        position.notional_value = dec!(0);
68        return Ok(());
69    }
70
71    let current_mark = derive_position_mark(position)?;
72    position.notional_value = amount * current_mark;
73    Ok(())
74}
75
76pub fn enrich_position_metrics(
77    mode: MarginMode,
78    contributions: Option<HashMap<String, PositionMarginMetrics>>,
79    standard_option_marks: Option<HashMap<String, Decimal>>,
80    portfolio: &mut Portfolio,
81) -> Result<(), PositionMetricsError> {
82    let contributions = contributions.unwrap_or_default();
83    let standard_option_marks = standard_option_marks.unwrap_or_default();
84
85    for position in &mut portfolio.positions {
86        update_current_notional_value(position)?;
87        let symbol = position.position.symbol.clone();
88
89        if matches!(mode, MarginMode::Standard) {
90            if position.position.amount < dec!(0) {
91                let contribution = contributions.get(&symbol).ok_or_else(|| {
92                    tracing::error!(
93                        "Missing standard margin contribution for short position {}",
94                        symbol
95                    );
96                    PositionMetricsError
97                })?;
98                position.position.margin_posted = contribution.initial_margin;
99                position.maintenance_margin = contribution.maintenance_margin;
100                position.margin_ratio = if position.notional_value.abs() > dec!(0) {
101                    contribution.initial_margin / position.notional_value.abs()
102                } else {
103                    dec!(0)
104                };
105            } else {
106                position.position.margin_posted = dec!(0);
107                position.maintenance_margin = dec!(0);
108                position.margin_ratio = dec!(0);
109            }
110        }
111
112        if ParsedSymbol::from_symbol(&symbol).is_err() || position.position.amount >= dec!(0) {
113            position.liquidation_price = dec!(0);
114            continue;
115        }
116
117        let current_mark = match mode {
118            MarginMode::Standard => {
119                standard_option_marks.get(&symbol).copied().ok_or_else(|| {
120                    tracing::error!("Missing standard option mark for short position {}", symbol);
121                    PositionMetricsError
122                })?
123            }
124            MarginMode::Portfolio => derive_position_mark(position)?,
125        };
126
127        let span_margin = portfolio.span_margin.as_ref().ok_or_else(|| {
128            tracing::error!("Missing span_margin in portfolio while enriching position metrics");
129            PositionMetricsError
130        })?;
131        position.liquidation_price = compute_short_option_liquidation_mark(
132            span_margin.equity,
133            span_margin.maintenance_margin_required,
134            position.position.amount,
135            current_mark,
136        )?;
137    }
138
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::update_current_notional_value;
145    use crate::api_models::{Position, PositionWithMetrics};
146    use crate::wallet_address::test_wallet;
147    use chrono::Utc;
148    use rust_decimal_macros::dec;
149
150    #[test]
151    fn current_notional_value_uses_current_mark() {
152        let mut position = PositionWithMetrics {
153            position: Position {
154                wallet_address: test_wallet(1),
155                symbol: "BTC-20261231-100000-C".to_string(),
156                amount: dec!(2),
157                entry_price: dec!(5000),
158                margin_posted: dec!(0),
159                realized_pnl: dec!(0),
160                unrealized_pnl: dec!(3000),
161                updated_at: Utc::now(),
162            },
163            notional_value: dec!(10000),
164            maintenance_margin: dec!(0),
165            liquidation_price: dec!(0),
166            margin_ratio: dec!(0),
167        };
168
169        update_current_notional_value(&mut position).expect("current notional should update");
170
171        assert_eq!(position.notional_value, dec!(13000));
172    }
173
174    #[test]
175    fn current_notional_value_zeroes_zero_size_positions() {
176        let mut position = PositionWithMetrics {
177            position: Position {
178                wallet_address: test_wallet(1),
179                symbol: "BTC-20261231-100000-C".to_string(),
180                amount: dec!(0),
181                entry_price: dec!(5000),
182                margin_posted: dec!(0),
183                realized_pnl: dec!(0),
184                unrealized_pnl: dec!(3000),
185                updated_at: Utc::now(),
186            },
187            notional_value: dec!(42),
188            maintenance_margin: dec!(0),
189            liquidation_price: dec!(0),
190            margin_ratio: dec!(0),
191        };
192
193        update_current_notional_value(&mut position).expect("zero-size position should update");
194
195        assert_eq!(position.notional_value, dec!(0));
196    }
197}