af_iperps/
math.rs

1use std::cmp::max;
2
3use af_move_type::MoveType;
4use af_utilities::{Balance9, IFixed};
5use num_traits::Zero as _;
6
7use crate::clearing_house::ClearingHouse;
8use crate::{MarketParams, MarketState, Position};
9
10#[derive(thiserror::Error, Debug)]
11#[non_exhaustive]
12pub enum Error {
13    #[error("Overflow when converting types")]
14    Overflow,
15    #[error("Not enough precision to represent price")]
16    Precision,
17}
18
19/// Convenience trait to convert to/from units used in the orderbook.
20pub trait OrderBookUnits {
21    fn price_to_ifixed(&self, price: u64) -> IFixed {
22        let price_ifixed = IFixed::from(price);
23        let lot_size_ifixed = IFixed::from(self.lot_size());
24        let tick_size_ifixed = IFixed::from(self.tick_size());
25        price_ifixed * tick_size_ifixed / lot_size_ifixed
26    }
27
28    /// The price in ticks/lot closest to the desired value.
29    ///
30    /// Note that this:
31    /// - rounds the equivalent ticks/lot **down** to the nearest integer.
32    /// - errors if the equivalent ticks/lot < 1, signaling not enough precision.
33    fn ifixed_to_price(&self, ifixed: IFixed) -> Result<u64, Error> {
34        if ifixed.is_zero() {
35            return Ok(0);
36        }
37        // ifixed = (price_ifixed * tick_size_ifixed) / lot_size_ifixed
38        // (ifixed * lot_size_ifixed) / tick_size_ifixed = price_ifixed
39        let price_ifixed =
40            (ifixed * IFixed::from(self.lot_size())) / IFixed::from(self.tick_size());
41        let price: u64 = price_ifixed
42            .integer()
43            .uabs()
44            .try_into()
45            .map_err(|_| Error::Overflow)?;
46        if price == 0 {
47            return Err(Error::Precision);
48        }
49        Ok(price)
50    }
51
52    fn lots_to_ifixed(&self, lots: u64) -> IFixed {
53        let ifixed_lots: IFixed = lots.into();
54        let ifixed_lot_size: IFixed = Balance9::from_inner(self.lot_size()).into();
55        ifixed_lots * ifixed_lot_size
56    }
57
58    fn ifixed_to_lots(&self, ifixed: IFixed) -> Result<u64, Error> {
59        let balance: Balance9 = ifixed.try_into().map_err(|_| Error::Overflow)?;
60        Ok(balance.into_inner() / self.lot_size())
61    }
62
63    // NOTE: these could be updated to return NonZeroU64 ensuring division by zero errors are
64    // impossible.
65    fn lot_size(&self) -> u64;
66    fn tick_size(&self) -> u64;
67}
68
69impl OrderBookUnits for MarketParams {
70    fn lot_size(&self) -> u64 {
71        self.lot_size
72    }
73
74    fn tick_size(&self) -> u64 {
75        self.tick_size
76    }
77}
78
79impl<T: MoveType> OrderBookUnits for ClearingHouse<T> {
80    fn lot_size(&self) -> u64 {
81        self.market_params.lot_size
82    }
83
84    fn tick_size(&self) -> u64 {
85        self.market_params.tick_size
86    }
87}
88
89impl<T: MoveType> ClearingHouse<T> {
90    /// Convenience method for computing a position's liquidation price.
91    ///
92    /// Forwards to [`Position::liquidation_price`].
93    pub fn liquidation_price(&self, pos: &Position, coll_price: IFixed) -> Option<IFixed> {
94        pos.liquidation_price(
95            coll_price,
96            self.market_state.cum_funding_rate_long,
97            self.market_state.cum_funding_rate_short,
98            self.market_params.margin_ratio_maintenance,
99        )
100    }
101}
102
103impl MarketParams {
104    /// The initial and maintenance margin requirements given a certain notional.
105    ///
106    /// All values in USD.
107    pub fn margin_requirements(&self, notional: IFixed) -> (IFixed, IFixed) {
108        let min_margin = notional * self.margin_ratio_initial;
109        let liq_margin = notional * self.margin_ratio_maintenance;
110        (min_margin, liq_margin)
111    }
112}
113
114impl MarketState {
115    /// Convenience method for computing a position's unrealized funding.
116    ///
117    /// Forwards to [`Position::unrealized_funding`].
118    pub fn unrealized_funding(&self, pos: &Position) -> IFixed {
119        pos.unrealized_funding(self.cum_funding_rate_long, self.cum_funding_rate_short)
120    }
121}
122
123impl Position {
124    pub fn liquidation_price(
125        &self,
126        coll_price: IFixed,
127        cum_funding_rate_long: IFixed,
128        cum_funding_rate_short: IFixed,
129        maintenance_margin_ratio: IFixed,
130    ) -> Option<IFixed> {
131        let coll = self.collateral * coll_price;
132        let ufunding = self.unrealized_funding(cum_funding_rate_long, cum_funding_rate_short);
133        let quote = self.quote_asset_notional_amount;
134
135        let size = self.base_asset_amount;
136        let bids_net_abs = (size + self.bids_quantity).abs();
137        let asks_net_abs = (size - self.asks_quantity).abs();
138        let max_abs_net_base = max(bids_net_abs, asks_net_abs);
139
140        let denominator = max_abs_net_base * maintenance_margin_ratio - size;
141        if denominator.is_zero() {
142            None
143        } else {
144            Some((coll + ufunding - quote) / denominator)
145        }
146    }
147
148    pub fn entry_price(&self) -> IFixed {
149        self.base_asset_amount / self.quote_asset_notional_amount
150    }
151
152    /// In USD.
153    pub fn unrealized_funding(
154        &self,
155        cum_funding_rate_long: IFixed,
156        cum_funding_rate_short: IFixed,
157    ) -> IFixed {
158        if self.base_asset_amount.is_neg() {
159            unrealized_funding(
160                cum_funding_rate_short,
161                self.cum_funding_rate_short,
162                self.base_asset_amount,
163            )
164        } else {
165            unrealized_funding(
166                cum_funding_rate_long,
167                self.cum_funding_rate_long,
168                self.base_asset_amount,
169            )
170        }
171    }
172
173    /// In USD.
174    pub fn unrealized_pnl(&self, price: IFixed) -> IFixed {
175        (self.base_asset_amount * price) - self.quote_asset_notional_amount
176    }
177
178    /// Total position value in USD. Used for risk calculations.
179    pub fn notional(&self, price: IFixed) -> IFixed {
180        let size = self.base_asset_amount;
181        let bids_net_abs = (size + self.bids_quantity).abs();
182        let asks_net_abs = (size - self.asks_quantity).abs();
183        let max_abs_net_base = max(bids_net_abs, asks_net_abs);
184        max_abs_net_base * price
185    }
186}
187
188fn unrealized_funding(
189    cum_funding_rate_now: IFixed,
190    cum_funding_rate_before: IFixed,
191    size: IFixed,
192) -> IFixed {
193    if cum_funding_rate_now == cum_funding_rate_before {
194        return IFixed::zero();
195    };
196
197    (cum_funding_rate_now - cum_funding_rate_before) * (-size)
198}
199
200#[cfg(test)]
201mod tests {
202    use std::num::NonZeroU64;
203
204    use proptest::prelude::*;
205    use test_strategy::{Arbitrary, proptest};
206
207    use super::*;
208
209    impl OrderBookUnits for (u64, u64) {
210        fn lot_size(&self) -> u64 {
211            self.0
212        }
213
214        fn tick_size(&self) -> u64 {
215            self.1
216        }
217    }
218
219    #[test]
220    fn orderbook_units() {
221        let mut units = (10_000_000, 1_000_000);
222        let mut ifixed: IFixed;
223
224        ifixed = u64::MAX.into();
225        ifixed += IFixed::from_inner(1.into());
226        insta::assert_snapshot!(ifixed, @"18446744073709551615.000000000000000001");
227        let err = units.ifixed_to_lots(ifixed).unwrap_err();
228        insta::assert_snapshot!(err, @"Overflow when converting types");
229
230        // Values smaller than 1 balance9 get cast to 0
231        ifixed = IFixed::from_inner(1.into());
232        insta::assert_snapshot!(ifixed, @"0.000000000000000001");
233        let ok = units.ifixed_to_lots(ifixed).unwrap();
234        assert_eq!(ok, 0);
235
236        ifixed = 0.001.try_into().unwrap();
237        insta::assert_snapshot!(ifixed, @"0.001");
238        let err = units.ifixed_to_price(ifixed).unwrap_err();
239        insta::assert_snapshot!(err, @"Not enough precision to represent price");
240
241        ifixed = 0.0.try_into().unwrap();
242        insta::assert_snapshot!(ifixed, @"0.0");
243        let ok = units.ifixed_to_price(ifixed).unwrap();
244        assert_eq!(ok, 0);
245
246        ifixed = 0.1.try_into().unwrap();
247        insta::assert_snapshot!(ifixed, @"0.1");
248        let ok = units.ifixed_to_price(ifixed).unwrap();
249        assert_eq!(ok, 1);
250
251        // `ifixed_to_price` truncates
252        ifixed = 0.15.try_into().unwrap();
253        insta::assert_snapshot!(ifixed, @"0.15");
254        let ok = units.ifixed_to_price(ifixed).unwrap();
255        assert_eq!(ok, 1);
256
257        ifixed = units.price_to_ifixed(0);
258        insta::assert_snapshot!(ifixed, @"0.0");
259
260        // Can handle an absurdly large price no problem
261        units = (1, u64::MAX);
262        let ok = units.price_to_ifixed(u64::MAX);
263        insta::assert_snapshot!(ok, @"340282366920938463426481119284349108225.0");
264
265        // Can handle an absurdly large lot size no problem
266        units = (u64::MAX, 1);
267        let ok = units.lots_to_ifixed(u64::MAX);
268        insta::assert_snapshot!(ok, @"340282366920938463426481119284.349108225");
269
270        units = (100000, 1000);
271        let min_amount = units.lots_to_ifixed(1);
272        insta::assert_snapshot!(min_amount, @"0.0001");
273        let price_precision = units.price_to_ifixed(1);
274        insta::assert_snapshot!(price_precision, @"0.01");
275    }
276
277    #[derive(Arbitrary, Debug)]
278    struct Contracts {
279        lots: NonZeroU64,
280        ticks: NonZeroU64,
281        short: bool,
282    }
283
284    impl Position {
285        fn from_contracts(
286            collateral: IFixed,
287            contracts: Contracts,
288            params: &impl OrderBookUnits,
289        ) -> Self {
290            let mut base = params.lots_to_ifixed(contracts.lots.into());
291            if contracts.short {
292                base = -base;
293            }
294            let mut quote = params.lots_to_ifixed(contracts.ticks.into());
295            if contracts.short {
296                quote = -quote;
297            }
298            Self {
299                collateral,
300                base_asset_amount: base,
301                quote_asset_notional_amount: quote,
302                cum_funding_rate_long: 0.into(),
303                cum_funding_rate_short: 0.into(),
304                asks_quantity: 0.into(),
305                bids_quantity: 0.into(),
306                pending_orders: 0,
307                maker_fee: 1.into(),
308                taker_fee: 1.into(),
309            }
310        }
311
312        fn empty(collateral: IFixed) -> Self {
313            Self {
314                collateral,
315                base_asset_amount: 0.into(),
316                quote_asset_notional_amount: 0.into(),
317                cum_funding_rate_long: 0.into(),
318                cum_funding_rate_short: 0.into(),
319                asks_quantity: 0.into(),
320                bids_quantity: 0.into(),
321                pending_orders: 0,
322                maker_fee: 1.into(),
323                taker_fee: 1.into(),
324            }
325        }
326    }
327
328    #[proptest]
329    fn liquidation_price_is_positive(
330        contracts: Contracts,
331        #[strategy(0.0001..=1e12)] coll_price: f64,
332        #[strategy(0.0001..=0.5)] maintenance_margin_ratio: f64,
333        #[strategy(1..=1_000_000_000_u64)] lot_size: u64,
334        #[strategy(1..=#lot_size)] tick_size: u64,
335    ) {
336        let position = Position::from_contracts(1.into(), contracts, &(lot_size, tick_size));
337        let liq_price = position
338            .liquidation_price(
339                coll_price.try_into().unwrap(),
340                IFixed::zero(),
341                IFixed::zero(),
342                maintenance_margin_ratio.try_into().unwrap(),
343            )
344            .unwrap();
345        dbg!(liq_price.to_string());
346        assert!(liq_price > IFixed::zero());
347    }
348
349    #[proptest]
350    fn liquidation_price_none(
351        #[strategy(any::<NonZeroU64>())]
352        #[map(|x: NonZeroU64| Balance9::from_inner(x.get()))]
353        collateral: Balance9,
354        #[strategy(0.0001..=1e12)] coll_price: f64,
355    ) {
356        let position = Position::empty(collateral.into());
357        let liq_price = position.liquidation_price(
358            coll_price.try_into().unwrap(),
359            IFixed::zero(),
360            IFixed::zero(),
361            0.001.try_into().unwrap(),
362        );
363        assert_eq!(liq_price, None);
364    }
365}