hyperdrive_math/short/
close.rs

1use ethers::types::U256;
2use eyre::{eyre, Result};
3use fixedpointmath::{fixed, FixedPoint};
4
5use crate::{State, YieldSpace};
6
7impl State {
8    fn calculate_close_short_flat<F: Into<FixedPoint<U256>>>(
9        &self,
10        bond_amount: F,
11        maturity_time: U256,
12        current_time: U256,
13    ) -> FixedPoint<U256> {
14        // NOTE: We overestimate the trader's share payment to avoid sandwiches.
15        let bond_amount = bond_amount.into();
16        let normalized_time_remaining =
17            self.calculate_normalized_time_remaining(maturity_time, current_time);
18        bond_amount.mul_div_up(
19            fixed!(1e18) - normalized_time_remaining,
20            self.vault_share_price(),
21        )
22    }
23
24    fn calculate_close_short_curve<F: Into<FixedPoint<U256>>>(
25        &self,
26        bond_amount: F,
27        maturity_time: U256,
28        current_time: U256,
29    ) -> Result<FixedPoint<U256>> {
30        let bond_amount = bond_amount.into();
31        let normalized_time_remaining =
32            self.calculate_normalized_time_remaining(maturity_time, current_time);
33        if normalized_time_remaining > fixed!(0) {
34            // NOTE: Round the `shareCurveDelta` up to overestimate the share
35            // payment.
36            //
37            let curve_bonds_in = bond_amount.mul_up(normalized_time_remaining);
38            Ok(self.calculate_shares_in_given_bonds_out_up(curve_bonds_in)?)
39        } else {
40            Ok(fixed!(0))
41        }
42    }
43
44    fn calculate_close_short_flat_plus_curve<F: Into<FixedPoint<U256>>>(
45        &self,
46        bond_amount: F,
47        maturity_time: U256,
48        current_time: U256,
49    ) -> Result<FixedPoint<U256>> {
50        let bond_amount = bond_amount.into();
51        // Calculate the flat part of the trade
52        let flat = self.calculate_close_short_flat(bond_amount, maturity_time, current_time);
53        // Calculate the curve part of the trade
54        let curve = self.calculate_close_short_curve(bond_amount, maturity_time, current_time)?;
55
56        Ok(flat + curve)
57    }
58
59    /// Calculates the proceeds in shares of closing a short position. This
60    /// takes into account the trading profits, the interest that was
61    /// earned by the short, the flat fee the short pays, and the amount of
62    /// margin that was released by closing the short. The adjusted value in
63    /// shares that underlies the bonds is given by:
64    ///
65    /// ```math
66    /// P_{\text{adj}} = \left( \frac{c1}{c_0 \cdot c} + \phi_f \right)
67    /// \cdot \frac{\Delta y}{c}
68    /// ```
69    ///
70    /// and the short proceeds are given by:
71    ///
72    /// ```math
73    /// \text{proceeds} =
74    /// \begin{cases}
75    ///     P_\text{adj} - dz,
76    ///       & \text{if } P_{\text{adj}} > dz \\
77    ///     0,              & \text{otherwise}
78    /// \end{cases}
79    /// ```
80    ///
81    /// where `$dz$` is the pool share adjustment. In the event that the
82    /// interest is negative and outweighs the trading profits and margin
83    /// released, the short's proceeds are marked to zero.
84    pub fn calculate_short_proceeds_up(
85        &self,
86        bond_amount: FixedPoint<U256>,
87        share_amount: FixedPoint<U256>,
88        open_vault_share_price: FixedPoint<U256>,
89        close_vault_share_price: FixedPoint<U256>,
90    ) -> FixedPoint<U256> {
91        // NOTE: Round up to overestimate the short proceeds.
92        //
93        // The total value is the amount of shares that underlies the bonds that
94        // were shorted. The bonds start by being backed 1:1 with base, and the
95        // total value takes into account all of the interest that has accrued
96        // since the short was opened.
97        //
98        // total_value = (c1 / (c0 * c)) * dy
99        let mut total_value = bond_amount
100            .mul_div_up(close_vault_share_price, open_vault_share_price)
101            .div_up(self.vault_share_price());
102
103        // NOTE: Round up to overestimate the short proceeds.
104        //
105        // We increase the total value by the flat fee amount, because it is
106        // included in the total amount of capital underlying the short.
107        total_value += bond_amount.mul_div_up(self.flat_fee(), self.vault_share_price());
108
109        // If the interest is more negative than the trading profits and margin
110        // released, then the short proceeds are marked to zero. Otherwise, we
111        // calculate the proceeds as the sum of the trading proceeds, the
112        // interest proceeds, and the margin released.
113        if total_value > share_amount {
114            // proceeds = (c1 / c0 * c) * dy - dz
115            total_value - share_amount
116        } else {
117            fixed!(0)
118        }
119    }
120
121    /// Calculates the proceeds in shares of closing a short position. This
122    /// takes into account the trading profits, the interest that was
123    /// earned by the short, the flat fee the short pays, and the amount of
124    /// margin that was released by closing the short. The adjusted value in
125    /// shares that underlies the bonds is given by:
126    ///
127    /// ```math
128    /// P_{\text{adj}} = \left( \frac{c1}{c_0 \cdot c} + \phi_f \right)
129    /// \cdot \frac{\Delta y}{c}
130    /// ```
131    ///
132    /// and the short proceeds are given by:
133    ///
134    /// ```math
135    /// \text{proceeds} =
136    /// \begin{cases}
137    ///     P_\text{adj} - dz
138    ///       & \text{if } P_{\text{adj}} > dz \\
139    ///     0,              & \text{otherwise}
140    /// \end{cases}
141    /// ```
142    ///
143    /// where `$dz$` is the pool share adjustment. In the event that the
144    /// interest is negative and outweighs the trading profits and margin
145    /// released, the short's proceeds are marked to zero.
146    fn calculate_short_proceeds_down(
147        &self,
148        bond_amount: FixedPoint<U256>,
149        share_amount: FixedPoint<U256>,
150        open_vault_share_price: FixedPoint<U256>,
151        close_vault_share_price: FixedPoint<U256>,
152    ) -> FixedPoint<U256> {
153        // NOTE: Round down to underestimate the short proceeds.
154        //
155        // The total value is the amount of shares that underlies the bonds that
156        // were shorted. The bonds start by being backed 1:1 with base, and the
157        // total value takes into account all of the interest that has accrued
158        // since the short was opened.
159        //
160        // total_value = (c1 / (c0 * c)) * dy
161        let mut total_value = bond_amount
162            .mul_div_down(close_vault_share_price, open_vault_share_price)
163            .div_down(self.vault_share_price());
164
165        // NOTE: Round down to underestimate the short proceeds.
166        //
167        // We increase the total value by the flat fee amount, because it is
168        // included in the total amount of capital underlying the short.
169        total_value += bond_amount.mul_div_down(self.flat_fee(), self.vault_share_price());
170
171        // If the interest is more negative than the trading profits and margin
172        // released, then the short proceeds are marked to zero. Otherwise, we
173        // calculate the proceeds as the sum of the trading proceeds, the
174        // interest proceeds, and the margin released.
175        if total_value > share_amount {
176            // proceeds = (c1 / c0 * c) * dy - dz
177            total_value - share_amount
178        } else {
179            fixed!(0)
180        }
181    }
182
183    /// Since traders pay a curve fee when they close shorts on Hyperdrive,
184    /// it is possible for traders to receive a negative interest rate even
185    /// if curve's spot price is less than or equal to 1.
186    //
187    /// Given the curve fee `$\phi_c$` and the starting spot price `$p_0$`, the
188    /// maximum spot price is given by:
189    ///
190    /// ```math
191    /// p_{\text{max}} = 1 - \phi_c \cdot (1 - p_0)
192    /// ```
193    fn calculate_close_short_max_spot_price(&self) -> Result<FixedPoint<U256>> {
194        Ok(fixed!(1e18)
195            - self
196                .curve_fee()
197                .mul_up(fixed!(1e18) - self.calculate_spot_price()?))
198    }
199
200    /// Calculates the amount of shares the trader will receive after fees for closing a short
201    pub fn calculate_close_short<F: Into<FixedPoint<U256>>>(
202        &self,
203        bond_amount: F,
204        open_vault_share_price: F,
205        close_vault_share_price: F,
206        maturity_time: U256,
207        current_time: U256,
208    ) -> Result<FixedPoint<U256>> {
209        let bond_amount = bond_amount.into();
210        let open_vault_share_price = open_vault_share_price.into();
211        let close_vault_share_price = close_vault_share_price.into();
212
213        if bond_amount < self.config.minimum_transaction_amount.into() {
214            return Err(eyre!("MinimumTransactionAmount: Input amount too low"));
215        }
216
217        // Ensure that the trader didn't purchase bonds at a negative interest
218        // rate after accounting for fees.
219        let share_curve_delta =
220            self.calculate_close_short_curve(bond_amount, maturity_time, current_time)?;
221        let bond_reserves_delta = bond_amount
222            .mul_up(self.calculate_normalized_time_remaining(maturity_time, current_time));
223        let short_curve_spot_price = {
224            let mut state: State = self.clone();
225            state.info.bond_reserves -= bond_reserves_delta.into();
226            state.info.share_reserves += share_curve_delta.into();
227            state.calculate_spot_price()?
228        };
229        let max_spot_price = self.calculate_close_short_max_spot_price()?;
230        if short_curve_spot_price > max_spot_price {
231            return Err(eyre!("InsufficientLiquidity: Negative Interest"));
232        }
233
234        // Ensure ending spot price is less than one
235        let curve_fee = self.close_short_curve_fee(bond_amount, maturity_time, current_time)?;
236        let share_curve_delta_with_fees = share_curve_delta + curve_fee
237            - self.close_short_governance_fee(
238                bond_amount,
239                maturity_time,
240                current_time,
241                Some(curve_fee),
242            )?;
243        let share_curve_delta_with_fees_spot_price = {
244            let mut state: State = self.clone();
245            state.info.bond_reserves -= bond_reserves_delta.into();
246            state.info.share_reserves += share_curve_delta_with_fees.into();
247            state.calculate_spot_price()?
248        };
249        if share_curve_delta_with_fees_spot_price > fixed!(1e18) {
250            return Err(eyre!("InsufficientLiquidity: Negative Interest"));
251        }
252
253        // Now calculate short proceeds
254        // TODO we've already calculated a couple of internal variables needed by this function,
255        // rework to avoid recalculating the curve and bond reserves
256        // https://github.com/delvtech/hyperdrive/issues/943
257        let share_reserves_delta =
258            self.calculate_close_short_flat_plus_curve(bond_amount, maturity_time, current_time)?;
259        // Calculate flat + curve and subtract the fees from the trade.
260        let share_reserves_delta_with_fees = share_reserves_delta
261            + self.close_short_curve_fee(bond_amount, maturity_time, current_time)?
262            + self.close_short_flat_fee(bond_amount, maturity_time, current_time);
263
264        // Calculate the share proceeds owed to the short.
265        Ok(self.calculate_short_proceeds_down(
266            bond_amount,
267            share_reserves_delta_with_fees,
268            open_vault_share_price,
269            close_vault_share_price,
270        ))
271    }
272
273    /// Calculates the amount of shares the trader will receive after fees for closing a short
274    /// assuming no slippage, market impact, or liquidity constraints. This is the spot valuation.
275    ///
276    /// To get this value, we use the same calculations as `calculate_close_short`, except
277    /// for the curve part of the trade, where we replace `calculate_shares_in_given_bonds_out`
278    /// for the following:
279    ///
280    /// `$\text{curve} = \tfrac{\Delta y}{c} \cdot p \cdot t$`
281    ///
282    /// `$\Delta y = \text{bond_amount}$`
283    /// `$c = \text{close_vault_share_price (current if non-matured)}$`
284    pub fn calculate_market_value_short<F: Into<FixedPoint<U256>>>(
285        &self,
286        bond_amount: F,
287        open_vault_share_price: F,
288        close_vault_share_price: F,
289        maturity_time: U256,
290        current_time: U256,
291    ) -> Result<FixedPoint<U256>> {
292        let bond_amount = bond_amount.into();
293        let open_vault_share_price = open_vault_share_price.into();
294        let close_vault_share_price = close_vault_share_price.into();
295
296        let spot_price = self.calculate_spot_price()?;
297        if spot_price > fixed!(1e18) {
298            return Err(eyre!("Negative fixed interest!"));
299        }
300
301        // get the time remaining
302        let time_remaining = self.calculate_normalized_time_remaining(maturity_time, current_time);
303
304        // calculate_close_short_flat = dy * (1 - t) / c
305        let flat = self.calculate_close_short_flat(bond_amount, maturity_time, current_time);
306
307        // curve = dy * p * t / c
308        let curve = bond_amount
309            .mul_up(spot_price)
310            .mul_up(time_remaining)
311            .div_up(self.vault_share_price());
312        let flat_fees_paid = self.close_short_flat_fee(bond_amount, maturity_time, current_time);
313        let curve_fees_paid =
314            self.close_short_curve_fee(bond_amount, maturity_time, current_time)?;
315
316        // calculate share_reserves_delta to use it for calculate_short_proceeds_down.
317        let share_reserves_delta = flat + curve;
318        let share_reserves_delta_with_fees =
319            share_reserves_delta + flat_fees_paid + curve_fees_paid;
320
321        // Calculate the share proceeds owed to the short.
322        // calculate_short_proceeds_down also takes the yield accrued into account
323        Ok(self.calculate_short_proceeds_down(
324            bond_amount,
325            share_reserves_delta_with_fees,
326            open_vault_share_price,
327            close_vault_share_price,
328        ))
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use std::panic;
335
336    use ethers::types::I256;
337    use fixedpointmath::int256;
338    use hyperdrive_test_utils::{chain::TestChain, constants::FAST_FUZZ_RUNS};
339    use rand::{thread_rng, Rng};
340
341    use super::*;
342
343    #[tokio::test]
344    async fn fuzz_calculate_close_short_after_maturity() -> Result<()> {
345        // TODO: This simulates a 0% variable rate because the vault share price
346        // does not change over time. We should write one with a positive rate.
347        let mut rng = thread_rng();
348        for _ in 0..*FAST_FUZZ_RUNS {
349            let state = rng.gen::<State>();
350            let in_ = rng.gen_range(fixed!(0)..=state.effective_share_reserves()?);
351            let open_vault_share_price = rng.gen_range(fixed!(0)..=state.vault_share_price());
352            // NOTE: The actual maturity time could be shy of position_duration
353            // if the checkpoint_duration does not evenly divide into
354            // position_duration.
355            let maturity_time = state.position_duration();
356            // Close a short just after it has matured.
357            let just_after_maturity = maturity_time + state.checkpoint_duration();
358            let base_earned_just_after_maturity = state.calculate_close_short(
359                in_,
360                open_vault_share_price,
361                state.vault_share_price(),
362                maturity_time.into(),
363                just_after_maturity.into(),
364            )? * state.vault_share_price();
365            // Close a short a good while after it has matured.
366            let well_after_maturity = just_after_maturity + fixed!(1e10);
367            let base_earned_well_after_maturity = state.calculate_close_short(
368                in_,
369                open_vault_share_price,
370                state.vault_share_price(),
371                maturity_time.into(),
372                well_after_maturity.into(),
373            )? * state.vault_share_price();
374            // Check that no extra money was earned.
375            assert!(
376                base_earned_well_after_maturity == base_earned_just_after_maturity,
377                "Trader should not have earned any more after maturity:
378                earned_well_after_maturity={:?} != earned_just_after_maturity={:?}",
379                base_earned_well_after_maturity,
380                base_earned_just_after_maturity
381            );
382        }
383        Ok(())
384    }
385
386    #[tokio::test]
387    async fn fuzz_sol_calculate_short_proceeds_up() -> Result<()> {
388        let chain = TestChain::new().await?;
389
390        // Fuzz the rust and solidity implementations against each other.
391        let mut rng = thread_rng();
392        for _ in 0..*FAST_FUZZ_RUNS {
393            let state = rng.gen::<State>();
394            let bond_amount = rng.gen_range(fixed!(0)..=state.bond_reserves());
395            let share_amount = rng.gen_range(fixed!(0)..=bond_amount);
396            let open_vault_share_price = rng.gen_range(fixed!(0)..=state.vault_share_price());
397            let actual = panic::catch_unwind(|| {
398                state.calculate_short_proceeds_up(
399                    bond_amount,
400                    share_amount,
401                    open_vault_share_price,
402                    state.vault_share_price(),
403                )
404            });
405            match chain
406                .mock_hyperdrive_math()
407                .calculate_short_proceeds_up(
408                    bond_amount.into(),
409                    share_amount.into(),
410                    open_vault_share_price.into(),
411                    state.vault_share_price().into(),
412                    state.vault_share_price().into(),
413                    state.flat_fee().into(),
414                )
415                .call()
416                .await
417            {
418                Ok(expected) => assert_eq!(actual.unwrap(), FixedPoint::from(expected)),
419                Err(_) => assert!(actual.is_err()),
420            }
421        }
422
423        Ok(())
424    }
425
426    #[tokio::test]
427    async fn fuzz_sol_calculate_short_proceeds_down() -> Result<()> {
428        let chain = TestChain::new().await?;
429
430        // Fuzz the rust and solidity implementations against each other.
431        let mut rng = thread_rng();
432        for _ in 0..*FAST_FUZZ_RUNS {
433            let state = rng.gen::<State>();
434            let bond_amount = rng.gen_range(fixed!(0)..=state.bond_reserves());
435            let share_amount = rng.gen_range(fixed!(0)..=bond_amount);
436            let open_vault_share_price = rng.gen_range(fixed!(0)..=state.vault_share_price());
437            let actual = panic::catch_unwind(|| {
438                state.calculate_short_proceeds_down(
439                    bond_amount,
440                    share_amount,
441                    open_vault_share_price,
442                    state.vault_share_price(),
443                )
444            });
445            match chain
446                .mock_hyperdrive_math()
447                .calculate_short_proceeds_down(
448                    bond_amount.into(),
449                    share_amount.into(),
450                    open_vault_share_price.into(),
451                    state.vault_share_price().into(),
452                    state.vault_share_price().into(),
453                    state.flat_fee().into(),
454                )
455                .call()
456                .await
457            {
458                Ok(expected) => assert_eq!(actual.unwrap(), FixedPoint::from(expected)),
459                Err(_) => assert!(actual.is_err()),
460            }
461        }
462
463        Ok(())
464    }
465
466    #[tokio::test]
467    async fn fuzz_sol_calculate_close_short_flat_plus_curve() -> Result<()> {
468        let chain = TestChain::new().await?;
469
470        // Fuzz the rust and solidity implementations against each other.
471        let mut rng = thread_rng();
472        for _ in 0..*FAST_FUZZ_RUNS {
473            let state = rng.gen::<State>();
474            let in_ = rng.gen_range(fixed!(0)..=state.bond_reserves());
475            let maturity_time = state.position_duration();
476            let current_time = rng.gen_range(fixed!(0)..=maturity_time);
477            let actual = panic::catch_unwind(|| {
478                state.calculate_close_short_flat_plus_curve(
479                    in_,
480                    maturity_time.into(),
481                    current_time.into(),
482                )
483            });
484
485            let normalized_time_remaining = state
486                .calculate_normalized_time_remaining(maturity_time.into(), current_time.into());
487            match chain
488                .mock_hyperdrive_math()
489                .calculate_close_short(
490                    state.effective_share_reserves()?.into(),
491                    state.bond_reserves().into(),
492                    in_.into(),
493                    normalized_time_remaining.into(),
494                    state.t().into(),
495                    state.c().into(),
496                    state.mu().into(),
497                )
498                .call()
499                .await
500            {
501                Ok(expected) => assert_eq!(actual.unwrap().unwrap(), FixedPoint::from(expected.2)),
502                Err(_) => assert!(actual.is_err() || actual.unwrap().is_err()),
503            }
504        }
505
506        Ok(())
507    }
508
509    // Tests close short with an amount smaller than the minimum.
510    #[tokio::test]
511    async fn test_close_short_min_txn_amount() -> Result<()> {
512        let mut rng = thread_rng();
513        let state = rng.gen::<State>();
514        let result = state.calculate_close_short(
515            (state.config.minimum_transaction_amount - 10).into(),
516            state.calculate_spot_price()?,
517            state.vault_share_price(),
518            0.into(),
519            0.into(),
520        );
521        assert!(result.is_err());
522        Ok(())
523    }
524
525    // Tests market valuation against hyperdrive valuation when closing a short.
526    // This function aims to give an estimated position value without considering
527    // slippage, market impact, or any other liquidity constraints. As such, its
528    // divergence with the hyperdrive valuation will grow under low liquidity
529    // conditions. For this reason, we relax the error tolerance in such cases.
530    #[tokio::test]
531    async fn test_calculate_market_value_short() -> Result<()> {
532        let tolerance = int256!(1e12); // 0.000001
533
534        // Fuzz the spot valuation and hyperdrive valuation against each other.
535        let mut rng = thread_rng();
536        for _ in 0..*FAST_FUZZ_RUNS {
537            let mut scaled_tolerance = tolerance;
538
539            let state = rng.gen::<State>();
540            let bond_amount = state.minimum_transaction_amount();
541            let open_vault_share_price = rng.gen_range(fixed!(0.5e18)..=fixed!(2.5e18));
542            let maturity_time = U256::try_from(state.position_duration())?;
543            let current_time = rng.gen_range(fixed!(0)..=FixedPoint::from(maturity_time));
544
545            // When the reserves ratio is too small, the market impact makes the error between
546            // the valuations larger, so we scale the test's tolerance up to make up for it,
547            // since this is meant to be an estimate that ignores liquidity constraints.
548            let reserves_ratio = state.effective_share_reserves()? / state.bond_reserves();
549            if reserves_ratio < fixed!(1e12) {
550                scaled_tolerance *= int256!(100);
551            } else if reserves_ratio < fixed!(1e14) {
552                scaled_tolerance *= int256!(10);
553            }
554
555            let hyperdrive_valuation = state.calculate_close_short(
556                bond_amount,
557                open_vault_share_price,
558                state.vault_share_price(),
559                maturity_time.into(),
560                current_time.into(),
561            )?;
562
563            let spot_valuation = state.calculate_market_value_short(
564                bond_amount,
565                open_vault_share_price,
566                state.vault_share_price(),
567                maturity_time.into(),
568                current_time.into(),
569            )?;
570
571            let error = if spot_valuation >= hyperdrive_valuation {
572                I256::try_from(spot_valuation - hyperdrive_valuation)?
573            } else {
574                I256::try_from(hyperdrive_valuation - spot_valuation)?
575            };
576
577            assert!(
578                error < scaled_tolerance,
579                "error {:?} exceeds tolerance of {}",
580                error,
581                scaled_tolerance
582            );
583        }
584
585        Ok(())
586    }
587}