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.lot_size
85    }
86
87    fn tick_size(&self) -> u64 {
88        self.tick_size
89    }
90}
91
92impl<T: MoveType> OrderBookUnits for ClearingHouse<T> {
93    fn lot_size(&self) -> u64 {
94        self.market_params.lot_size
95    }
96
97    fn tick_size(&self) -> u64 {
98        self.market_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.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.margin_ratio_initial;
120        let liq_margin = notional * self.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
148        let base = self.base_asset_amount;
149        let bids_net_abs = (base + self.bids_quantity).abs();
150        let asks_net_abs = (base - self.asks_quantity).abs();
151        let max_abs_net_base = max(bids_net_abs, asks_net_abs);
152
153        println!("Base: {base}");
154        println!("Quote: {quote}");
155        let denominator = max_abs_net_base * maintenance_margin_ratio - base;
156        println!("Den: {}", denominator);
157
158        if denominator.is_zero() {
159            None
160        } else {
161            let num = coll + ufunding - quote;
162            println!("Num: {}", num);
163
164            Some((coll + ufunding - quote) / denominator)
165        }
166    }
167
168    /// Entry price of the position's contracts; in the same units as the oracle index price.
169    ///
170    /// This function returns `None` if the position has no open contracts, i.e., if
171    /// [`Self::base_asset_amount`] is zero.
172    pub fn entry_price(&self) -> Option<IFixed> {
173        if self.base_asset_amount.is_zero() {
174            return None;
175        }
176        Some(self.quote_asset_notional_amount / self.base_asset_amount)
177    }
178
179    /// The funding yet to be settled in this position given the market's current cumulative
180    /// fundings.
181    ///
182    /// The return value is in the same quote currency that the index price uses. E.g., if the
183    /// index price is USD/BTC, then the unrealized funding is in USD units.
184    pub fn unrealized_funding(
185        &self,
186        cum_funding_rate_long: IFixed,
187        cum_funding_rate_short: IFixed,
188    ) -> IFixed {
189        if self.base_asset_amount.is_neg() {
190            unrealized_funding(
191                cum_funding_rate_short,
192                self.cum_funding_rate_short,
193                self.base_asset_amount,
194            )
195        } else {
196            unrealized_funding(
197                cum_funding_rate_long,
198                self.cum_funding_rate_long,
199                self.base_asset_amount,
200            )
201        }
202    }
203
204    /// Unrealized PnL given an index price.
205    ///
206    /// The returned value is in the same currency as what the index price is quoted at. E.g., if
207    /// the index price is a ratio of BTC/ETH, then the PnL is in BTC units.
208    pub fn unrealized_pnl(&self, price: IFixed) -> IFixed {
209        (self.base_asset_amount * price) - self.quote_asset_notional_amount
210    }
211
212    /// Total position value used for risk calculations.
213    ///
214    /// The returned value is in the same currency as what the index price is quoted at. E.g., if
215    /// the index price is a ratio of BTC/ETH, then the PnL is in BTC units.
216    pub fn notional(&self, price: IFixed) -> IFixed {
217        let size = self.base_asset_amount;
218        let bids_net_abs = (size + self.bids_quantity).abs();
219        let asks_net_abs = (size - self.asks_quantity).abs();
220        let max_abs_net_base = max(bids_net_abs, asks_net_abs);
221        max_abs_net_base * price
222    }
223}
224
225fn unrealized_funding(
226    cum_funding_rate_now: IFixed,
227    cum_funding_rate_before: IFixed,
228    size: IFixed,
229) -> IFixed {
230    if cum_funding_rate_now == cum_funding_rate_before {
231        return IFixed::zero();
232    };
233
234    (cum_funding_rate_now - cum_funding_rate_before) * (-size)
235}
236
237#[cfg(test)]
238mod tests {
239    use std::num::NonZeroU64;
240
241    use af_utilities::Balance9;
242    use proptest::prelude::*;
243    use test_strategy::proptest;
244
245    use super::*;
246
247    impl OrderBookUnits for (u64, u64) {
248        fn lot_size(&self) -> u64 {
249            self.0
250        }
251
252        fn tick_size(&self) -> u64 {
253            self.1
254        }
255    }
256
257    #[test]
258    fn orderbook_units() {
259        let mut units = (10_000_000, 1_000_000);
260        let mut ifixed: IFixed;
261
262        ifixed = u64::MAX.into();
263        ifixed += IFixed::from_inner(1.into());
264        insta::assert_snapshot!(ifixed, @"18446744073709551615.000000000000000001");
265        let err = units.ifixed_to_size(ifixed).unwrap_err();
266        insta::assert_snapshot!(err, @"Overflow when converting types");
267
268        // Values smaller than 1 balance9 get cast to 0
269        ifixed = IFixed::from_inner(1.into());
270        insta::assert_snapshot!(ifixed, @"0.000000000000000001");
271        let ok = units.ifixed_to_size(ifixed).unwrap();
272        assert_eq!(ok, 0);
273
274        // Values smaller than 1 balance9 get cast to 0
275        ifixed = IFixed::from_inner(1.into());
276        insta::assert_snapshot!(ifixed, @"0.000000000000000001");
277        let ok = units.ifixed_to_price(ifixed).unwrap();
278        assert_eq!(ok, 0);
279
280        ifixed = 0.0.try_into().unwrap();
281        insta::assert_snapshot!(ifixed, @"0.0");
282        let ok = units.ifixed_to_price(ifixed).unwrap();
283        assert_eq!(ok, 0);
284
285        ifixed = 0.1.try_into().unwrap();
286        insta::assert_snapshot!(ifixed, @"0.1");
287        let ok = units.ifixed_to_price(ifixed).unwrap();
288        assert_eq!(ok, 0_100000000);
289
290        // `ifixed_to_price` truncates
291        ifixed = 0.15.try_into().unwrap();
292        insta::assert_snapshot!(ifixed, @"0.15");
293        let ok = units.ifixed_to_price(ifixed).unwrap();
294        assert_eq!(ok, 0_150000000);
295
296        ifixed = units.price_to_ifixed(0);
297        insta::assert_snapshot!(ifixed, @"0.0");
298
299        // Can handle a large price
300        units = (1, u64::MAX);
301        let ok = units.price_to_ifixed(u64::MAX);
302        insta::assert_snapshot!(ok, @"18446744073.709551615");
303
304        // Can handle a large lot size
305        units = (u64::MAX, 1);
306        let ok = units.size_to_ifixed(u64::MAX);
307        insta::assert_snapshot!(ok, @"18446744073.709551615");
308    }
309
310    #[test]
311    #[should_panic]
312    fn zero_lot_and_tick() {
313        (0u64, 0u64).price_to_ifixed(1);
314    }
315
316    #[test]
317    #[should_panic]
318    fn zero_lot() {
319        (0u64, 1u64).size_to_ifixed(1);
320    }
321
322    #[test]
323    #[should_panic]
324    fn zero_tick() {
325        assert_eq!((1u64, 0u64).price_to_ifixed(1), IFixed::zero());
326    }
327
328    #[test]
329    #[should_panic]
330    fn ifixed_to_price() {
331        (1u64, 0u64).ifixed_to_price(IFixed::one()).expect("Panics");
332    }
333
334    // #[derive(Arbitrary, Debug)]
335    // struct Contracts {
336    //     size: NonZeroU64,
337    //     price: NonZeroU64,
338    //     short: bool,
339    // }
340
341    impl Position {
342        // fn from_contracts(
343        //     collateral: IFixed,
344        //     contracts: Contracts,
345        //     params: &impl OrderBookUnits,
346        // ) -> Self {
347        //     let mut base = params.size_to_ifixed(contracts.size.into());
348        //     if contracts.short {
349        //         base = -base;
350        //     }
351        //     let mut quote = params.size_to_ifixed(contracts.price.into());
352        //     if contracts.short {
353        //         quote = -quote;
354        //     }
355        //     Self {
356        //         collateral,
357        //         base_asset_amount: base,
358        //         quote_asset_notional_amount: quote,
359        //         cum_funding_rate_long: 0.into(),
360        //         cum_funding_rate_short: 0.into(),
361        //         asks_quantity: 0.into(),
362        //         bids_quantity: 0.into(),
363        //         pending_orders: 0,
364        //         maker_fee: 1.into(),
365        //         taker_fee: 1.into(),
366        //         initial_margin_ratio: 1.into(),
367        //     }
368        // }
369
370        fn empty(collateral: IFixed) -> Self {
371            Self {
372                collateral,
373                base_asset_amount: 0.into(),
374                quote_asset_notional_amount: 0.into(),
375                cum_funding_rate_long: 0.into(),
376                cum_funding_rate_short: 0.into(),
377                asks_quantity: 0.into(),
378                bids_quantity: 0.into(),
379                pending_orders: 0,
380                maker_fee: 1.into(),
381                taker_fee: 1.into(),
382                initial_margin_ratio: 1.into(),
383            }
384        }
385    }
386
387    // #[proptest]
388    // fn liquidation_price_is_positive(
389    //     contracts: Contracts,
390    //     #[strategy(0.0001..=1e12)] coll_price: f64,
391    //     #[strategy(0.0001..=0.5)] maintenance_margin_ratio: f64,
392    //     #[strategy(1..=1_000_000_000_u64)] lot_size: u64,
393    //     #[strategy(1..=#lot_size)] tick_size: u64,
394    // ) {
395    //     println!("Contracts: {:?}", contracts);
396    //     println!("Coll price: {:?}", coll_price);
397    //     println!("MMR: {:?}", maintenance_margin_ratio);
398    //     println!("Lot size: {:?}", lot_size);
399    //     println!("Tick size: {:?}", tick_size);
400
401    //     let position = Position::from_contracts(1.into(), contracts, &(lot_size, tick_size));
402    //     println!("Position: {:?}", position);
403
404    //     let liq_price = position
405    //         .liquidation_price(
406    //             coll_price.try_into().unwrap(),
407    //             IFixed::zero(),
408    //             IFixed::zero(),
409    //             maintenance_margin_ratio.try_into().unwrap(),
410    //         )
411    //         .unwrap();
412    //     dbg!(liq_price.to_string());
413    //     assert!(liq_price > IFixed::zero());
414    // }
415
416    #[proptest]
417    fn liquidation_price_none(
418        #[strategy(any::<NonZeroU64>())]
419        #[map(|x: NonZeroU64| Balance9::from_inner(x.get()))]
420        collateral: Balance9,
421        #[strategy(0.0001..=1e12)] coll_price: f64,
422    ) {
423        let position = Position::empty(collateral.into());
424        let liq_price = position.liquidation_price(
425            coll_price.try_into().unwrap(),
426            IFixed::zero(),
427            IFixed::zero(),
428            0.001.try_into().unwrap(),
429        );
430        assert_eq!(liq_price, None);
431    }
432}