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