hyperdrive_math/long/
close.rs

1use ethers::types::U256;
2use eyre::{eyre, Result};
3use fixedpointmath::{fixed, FixedPoint};
4
5use crate::{State, YieldSpace};
6
7impl State {
8    /// Calculates the amount of shares the trader will receive after fees for closing a long
9    pub fn calculate_close_long<F: Into<FixedPoint<U256>>>(
10        &self,
11        bond_amount: F,
12        maturity_time: U256,
13        current_time: U256,
14    ) -> Result<FixedPoint<U256>> {
15        let bond_amount = bond_amount.into();
16
17        if bond_amount < self.config.minimum_transaction_amount.into() {
18            return Err(eyre!("MinimumTransactionAmount: Input amount too low"));
19        }
20
21        // Calculate the long proceeds and fees
22        let long_proceeds =
23            self.calculate_close_long_flat_plus_curve(bond_amount, maturity_time, current_time)?;
24        let long_curve_fee = self.close_long_curve_fee(bond_amount, maturity_time, current_time)?;
25        let long_flat_fee = self.close_long_flat_fee(bond_amount, maturity_time, current_time);
26
27        // Catch the underflow caused by the fees exceeding the proceeds
28        if long_proceeds < (long_curve_fee + long_flat_fee) {
29            return Err(eyre!(
30                "Closing the long results in fees exceeding the long proceeds."
31            ));
32        }
33
34        // Subtract the fees from the trade
35        Ok(long_proceeds - long_curve_fee - long_flat_fee)
36    }
37
38    /// Calculate the amount of shares returned when selling bonds without considering fees.
39    fn calculate_close_long_flat_plus_curve<F: Into<FixedPoint<U256>>>(
40        &self,
41        bond_amount: F,
42        maturity_time: U256,
43        current_time: U256,
44    ) -> Result<FixedPoint<U256>> {
45        let bond_amount = bond_amount.into();
46        let normalized_time_remaining =
47            self.calculate_normalized_time_remaining(maturity_time, current_time);
48
49        // Calculate the flat part of the trade
50        let flat = bond_amount.mul_div_down(
51            fixed!(1e18) - normalized_time_remaining,
52            self.vault_share_price(),
53        );
54
55        // Calculate the curve part of the trade
56        let curve = if normalized_time_remaining > fixed!(0) {
57            let curve_bonds_in = bond_amount * normalized_time_remaining;
58            self.calculate_shares_out_given_bonds_in_down(curve_bonds_in)?
59        } else {
60            fixed!(0)
61        };
62
63        Ok(flat + curve)
64    }
65
66    /// Calculates the amount of shares the trader will receive after fees for closing a long
67    /// assuming no slippage, market impact, or liquidity constraints. This is the spot valuation.
68    ///
69    /// To get this value, we use the same calculations as `calculate_close_long`, except
70    /// for the curve part of the trade, where we replace `calculate_shares_out_given_bonds_in`
71    /// for the following:
72    ///
73    /// `$\text{curve} = \tfrac{\Delta y}{c} \cdot p \cdot t$`
74    ///
75    /// `$\Delta y = \text{bond_amount}$`
76    /// `$c = \text{close_vault_share_price (current if non-matured)}$`
77    pub fn calculate_market_value_long<F: Into<FixedPoint<U256>>>(
78        &self,
79        bond_amount: F,
80        maturity_time: U256,
81        current_time: U256,
82    ) -> Result<FixedPoint<U256>> {
83        let bond_amount = bond_amount.into();
84
85        let spot_price = self.calculate_spot_price()?;
86        if spot_price > fixed!(1e18) {
87            return Err(eyre!("Negative fixed interest!"));
88        }
89
90        // get the time remaining
91        let time_remaining = self.calculate_normalized_time_remaining(maturity_time, current_time);
92
93        // let flat_value = bond_amount * (fixed!(1e18) - time_remaining);
94        let flat_value =
95            bond_amount.mul_div_down(fixed!(1e18) - time_remaining, self.vault_share_price());
96        let curve_bonds = bond_amount * time_remaining;
97        let curve_value = curve_bonds * spot_price / self.vault_share_price();
98
99        let trading_proceeds = flat_value + curve_value;
100        let flat_fees_paid = self.close_long_flat_fee(bond_amount, maturity_time, current_time);
101        let curve_fees_paid =
102            self.close_long_curve_fee(bond_amount, maturity_time, current_time)?;
103        let fees_paid = flat_fees_paid + curve_fees_paid;
104
105        if fees_paid > trading_proceeds {
106            Ok(fixed!(0))
107        } else {
108            Ok(trading_proceeds - flat_fees_paid - curve_fees_paid)
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use ethers::types::I256;
116    use fixedpointmath::int256;
117    use hyperdrive_test_utils::{chain::TestChain, constants::FAST_FUZZ_RUNS};
118    use rand::{thread_rng, Rng};
119
120    use super::*;
121
122    #[tokio::test]
123    async fn fuzz_calculate_close_long_after_maturity() -> Result<()> {
124        // TODO: This simulates a 0% variable rate because the vault share price
125        // does not change over time. We should write one with a positive rate.
126        let mut rng = thread_rng();
127        for _ in 0..*FAST_FUZZ_RUNS {
128            let state = rng.gen::<State>();
129            let in_ = rng.gen_range(fixed!(0)..=state.effective_share_reserves()?);
130            // NOTE: The actual maturity time could be shy of position_duration
131            // if the checkpoint_duration does not evenly divide into
132            // position_duration.
133            let maturity_time = state.position_duration();
134            // Close a long just after it has matured.
135            let just_after_maturity = maturity_time + state.checkpoint_duration();
136            let base_earned_just_after_maturity = state.calculate_close_long(
137                in_,
138                maturity_time.into(),
139                just_after_maturity.into(),
140            )? * state.vault_share_price();
141            // Close a long a good while after it has matured.
142            let well_after_maturity = just_after_maturity + fixed!(1e10);
143            let base_earned_well_after_maturity = state.calculate_close_long(
144                in_,
145                maturity_time.into(),
146                well_after_maturity.into(),
147            )? * state.vault_share_price();
148            // Check that no extra money was earned.
149            assert!(
150                base_earned_well_after_maturity == base_earned_just_after_maturity,
151                "Trader should not have earned any more after maturity:
152                earned_well_after_maturity={:?} != earned_just_after_maturity={:?}",
153                base_earned_well_after_maturity,
154                base_earned_just_after_maturity
155            );
156        }
157        Ok(())
158    }
159
160    #[tokio::test]
161    async fn fuzz_sol_calculate_close_long_flat_plus_curve() -> Result<()> {
162        let chain = TestChain::new().await?;
163
164        // Fuzz the rust and solidity implementations against each other.
165        let mut rng = thread_rng();
166        for _ in 0..*FAST_FUZZ_RUNS {
167            let state = rng.gen::<State>();
168            let in_ = rng.gen_range(fixed!(0)..=state.effective_share_reserves()?);
169            let maturity_time = state.position_duration();
170            let current_time = rng.gen_range(fixed!(0)..=maturity_time);
171            let normalized_time_remaining = state
172                .calculate_normalized_time_remaining(maturity_time.into(), current_time.into());
173            let actual = state.calculate_close_long_flat_plus_curve(
174                in_,
175                maturity_time.into(),
176                current_time.into(),
177            );
178            match chain
179                .mock_hyperdrive_math()
180                .calculate_close_long(
181                    state.effective_share_reserves()?.into(),
182                    state.bond_reserves().into(),
183                    in_.into(),
184                    normalized_time_remaining.into(),
185                    state.t().into(),
186                    state.c().into(),
187                    state.mu().into(),
188                )
189                .call()
190                .await
191            {
192                Ok(expected) => assert_eq!(actual.unwrap(), FixedPoint::from(expected.2)),
193                Err(_) => assert!(actual.is_err()),
194            }
195        }
196
197        Ok(())
198    }
199
200    // Tests close long with an amount smaller than the minimum.
201    #[tokio::test]
202    async fn test_close_long_min_txn_amount() -> Result<()> {
203        let mut rng = thread_rng();
204        let state = rng.gen::<State>();
205        let result = state.calculate_close_long(
206            state.config.minimum_transaction_amount - 10,
207            0.into(),
208            0.into(),
209        );
210        assert!(result.is_err());
211        Ok(())
212    }
213
214    // Tests market valuation against hyperdrive valuation when closing a long.
215    // This function aims to give an estimated position value without considering
216    // slippage, market impact, or any other liquidity constraints.
217    #[tokio::test]
218    async fn test_calculate_market_value_long() -> Result<()> {
219        let tolerance = int256!(1e12); // 0.000001
220
221        // Fuzz the spot valuation and hyperdrive valuation against each other.
222        let mut rng = thread_rng();
223        for _ in 0..*FAST_FUZZ_RUNS {
224            let mut scaled_tolerance = tolerance;
225
226            let state = rng.gen::<State>();
227            let bond_amount = state.minimum_transaction_amount();
228            let maturity_time = U256::try_from(state.position_duration())?;
229            let current_time = rng.gen_range(fixed!(0)..=FixedPoint::from(maturity_time));
230
231            // Ensure curve_fee is smaller than spot_price to avoid overflows
232            // on the hyperdrive valuation, as that'd mean having to pay a larger
233            // amount of fees than the current value of the long.
234            let spot_price = state.calculate_spot_price()?;
235            if state.curve_fee() * (fixed!(1e18) - spot_price) > spot_price {
236                continue;
237            }
238
239            // When the reserves ratio is too small, the market impact makes the error between
240            // the valuations larger, so we scale the test's tolerance up to make up for it,
241            // since this is meant to be an estimate that ignores liquidity constraints.
242            let reserves_ratio = state.effective_share_reserves()? / state.bond_reserves();
243            if reserves_ratio < fixed!(1e12) {
244                scaled_tolerance *= int256!(100);
245            } else if reserves_ratio < fixed!(1e14) {
246                scaled_tolerance *= int256!(10);
247            }
248
249            let hyperdrive_valuation = state.calculate_close_long(
250                bond_amount,
251                maturity_time.into(),
252                current_time.into(),
253            )?;
254
255            let spot_valuation = state.calculate_market_value_long(
256                bond_amount,
257                maturity_time.into(),
258                current_time.into(),
259            )?;
260
261            let diff = spot_valuation
262                .abs_diff(hyperdrive_valuation)
263                .change_type::<I256>()?
264                .raw();
265
266            assert!(
267                diff < scaled_tolerance,
268                r#"
269    hyperdrive_valuation: {hyperdrive_valuation}
270          spot_valuation: {spot_valuation}
271                    diff: {diff}
272               tolerance: {scaled_tolerance}
273"#,
274            );
275        }
276
277        Ok(())
278    }
279}