hypercall_types/
position_metrics.rs1use 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}