af_iperps/
math.rs

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