Skip to content

Commit

Permalink
abs max short
Browse files Browse the repository at this point in the history
  • Loading branch information
dpaiton committed Nov 24, 2024
1 parent 4cc1f81 commit 0cbfffe
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 62 deletions.
203 changes: 141 additions & 62 deletions crates/hyperdrive-math/src/short/max.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ impl State {
/// share reserves are equal to the minimum share reserves.
///
/// We can solve for the bond reserves `$y_{\text{max}}$` implied by the
/// share reserves being equal to `$z_{\text{min}}$` using the current $k$
/// share reserves being equal to `$z_{\text{min}}$` using the current `$k$`
/// value:
///
/// ```math
Expand Down Expand Up @@ -271,6 +271,7 @@ impl State {
let absolute_max_bond_amount = self.calculate_absolute_max_short(
spot_price,
checkpoint_exposure,
None,
maybe_max_iterations,
)?;
// The max bond amount might be below the pool's minimum. If so, no
Expand Down Expand Up @@ -499,17 +500,11 @@ impl State {
&self,
spot_price: FixedPoint<U256>,
checkpoint_exposure: I256,
maybe_tolerance: Option<FixedPoint<U256>>,
maybe_max_iterations: Option<usize>,
) -> Result<FixedPoint<U256>> {
// We start by calculating the maximum short that can be opened on the
// YieldSpace curve.
let yieldspace_max_delta_bonds = self.calculate_max_short_upper_bound()?;
if self
.solvency_after_short(yieldspace_max_delta_bonds, checkpoint_exposure)
.is_ok()
{
return Ok(yieldspace_max_delta_bonds);
}
let tolerance = maybe_tolerance.unwrap_or(fixed!(1e9));
let max_iterations = maybe_max_iterations.unwrap_or(7);

// Use Newton's method to iteratively approach a solution. We use pool's
// solvency $S(\Delta y)$ w.r.t. the amount of bonds shorted $\Delta y$
Expand All @@ -531,41 +526,53 @@ impl State {
//
// The guess that we make is very important in determining how quickly
// we converge to the solution.
let mut max_bond_guess = self.absolute_max_short_guess(checkpoint_exposure)?;
let mut last_good_bond_amount = self.absolute_max_short_guess(checkpoint_exposure)?;
let mut current_bond_amount: FixedPoint<U256>;
// If the initial guess is insolvent, we need to throw an error.
let mut solvency = self.solvency_after_short(max_bond_guess, checkpoint_exposure)?;
for _ in 0..maybe_max_iterations.unwrap_or(7) {
// TODO: It may be better to gracefully handle crossing over the
// root by extending the fixed point math library to handle negative
// numbers or even just using an if-statement to handle the negative
// numbers.
//
// Calculate the next iteration of Newton's method. If the candidate
// is larger than the absolute max, we've gone too far and something
// has gone wrong.
let derivative = match self.solvency_after_short_derivative(max_bond_guess, spot_price)
{
Ok(derivative) => derivative,
Err(_) => break,
};
let possible_max_bond_amount = max_bond_guess + solvency / derivative;
if possible_max_bond_amount > yieldspace_max_delta_bonds {
break;
}
let mut solvency = FixedPoint::from(U256::MAX);
for _ in 0..max_iterations {
// Calculate the current solvency.
solvency = self.solvency_after_short(last_good_bond_amount, checkpoint_exposure)?;

// Calculate the derivative to determine the next iteration of
// Newton's method.
let solvency_derivative =
self.solvency_after_short_derivative(last_good_bond_amount, spot_price)?;
let dy = solvency.div_up(solvency_derivative);

// Update our guess.
// Round up to discourage dy==0.
current_bond_amount = last_good_bond_amount + dy;

// If the candidate is insolvent, we've gone too far and can stop
// iterating. Otherwise, we update our guess and continue.
solvency =
match self.solvency_after_short(possible_max_bond_amount, checkpoint_exposure) {
last_good_bond_amount =
match self.solvency_after_short(current_bond_amount, checkpoint_exposure) {
Ok(solvency) => {
max_bond_guess = possible_max_bond_amount;
solvency
// If solvency is close enough to zero, return.
if solvency <= tolerance {
return Ok(last_good_bond_amount);
}
// Otherwise, iterate.
current_bond_amount
}
Err(_) => break,
// The new bond amount is not solvent because we overshot.
// Start again from slightly below the last good amount.
Err(_) => {
last_good_bond_amount / fixed!(2e18)
},
};
}

Ok(max_bond_guess)
// We did not find a solution within tolerance in the provided number of
// iterations.
return Err(eyre!(
"Could not converge to a bond amount given max iterations = {:#?}.
solvency={:#?}
tolerance={:#?}",
max_iterations,
solvency,
tolerance
));
}

/// Calculates an initial guess for the absolute max short. This is a
Expand Down Expand Up @@ -658,8 +665,8 @@ impl State {
///
/// ```math
/// z(\Delta y) = z_0 - \left(
/// P(\Delta y) - \left( \tfrac{c(\Delta y)}{c}
/// - \tfrac{g(\Delta y)}{c} \right)
/// P(\Delta y) - \left( \tfrac{\Phi_c(\Delta y)}{c}
/// - \tfrac{\Phi_g(\Delta y)}{c} \right)
/// \right)
/// ```
///
Expand Down Expand Up @@ -697,15 +704,14 @@ impl State {
pool_share_delta
));
}
// Check z - zeta >= z_min.
// Need to check that z - zeta >= z_min
let new_share_reserves = self.share_reserves() - pool_share_delta;
let new_effective_share_reserves =
calculate_effective_share_reserves(new_share_reserves, self.share_adjustment())?;
let new_effective_share_reserves = calculate_effective_share_reserves(new_share_reserves, self.share_adjustment())?;
if new_effective_share_reserves < self.minimum_share_reserves() {
return Err(eyre!("Insufficient liquidity. Expected effective_share_reserves={:#?} >= min_share_reserves={:#?}",
new_effective_share_reserves, self.minimum_share_reserves()));
}
// Check global exposure, which also checks z >= z_min.
// Check global exposure. This also ensures z >= z_min.
let exposure_shares = {
let checkpoint_exposure = FixedPoint::try_from(checkpoint_exposure.max(I256::zero()))?;
if self.long_exposure() < checkpoint_exposure {
Expand All @@ -715,7 +721,6 @@ impl State {
checkpoint_exposure
));
} else {
// Div up to make the check more conservative.
(self.long_exposure() - checkpoint_exposure).div_up(self.vault_share_price())
}
};
Expand Down Expand Up @@ -768,8 +773,10 @@ impl State {
mod tests {
use std::panic;

use ethers::types::{U128, U256};
use fixedpointmath::{fixed, uint256};
use fixedpointmath::FixedPoint;

use ethers::types::{U128, U256, I256};
use fixedpointmath::{fixed, fixed_u256, uint256};
use hyperdrive_test_utils::{
chain::TestChain,
constants::{FAST_FUZZ_RUNS, FUZZ_RUNS, SLOW_FUZZ_RUNS},
Expand Down Expand Up @@ -810,6 +817,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
Some(test_tolerance),
Some(max_iterations),
)
}) {
Expand Down Expand Up @@ -879,7 +887,7 @@ mod tests {
Ok(())
}

/// Test to ensure that the absolute max short guess is always solvent.
/// Test to ensure that the yieldspace max short is always solvent.
#[tokio::test]
async fn fuzz_calculate_absolute_max_short_guess() -> Result<()> {
let solvency_tolerance = fixed!(100_000_000e18);
Expand All @@ -895,22 +903,13 @@ mod tests {
} else {
I256::try_from(value)?
}
}
.min(I256::try_from(state.long_exposure())?);

let min_share_reserves = state.calculate_min_share_reserves(checkpoint_exposure)?;
}.min(I256::try_from(state.long_exposure())?);

// Make sure a short is possible.
if state
.effective_share_reserves()?
.min(state.share_reserves())
< min_share_reserves
{
continue;
if state.effective_share_reserves()?.min(state.share_reserves()) < state.calculate_min_share_reserves(checkpoint_exposure)? {
continue
}
match state
.solvency_after_short(state.minimum_transaction_amount(), checkpoint_exposure)
{
match state.solvency_after_short(state.minimum_transaction_amount(), checkpoint_exposure) {
Ok(_) => (),
Err(_) => continue,
}
Expand All @@ -919,7 +918,8 @@ mod tests {
let max_short_guess = state.absolute_max_short_guess(checkpoint_exposure)?;
let solvency = state.solvency_after_short(max_short_guess, checkpoint_exposure)?;

// Check that the remaining available shares in the pool are below a tolerance.
// Check that the remaining available shares in the pool are below a
// tolerance.
assert!(
solvency <= solvency_tolerance,
"solvency={:#?} > solvency_tolerance={:#?}",
Expand All @@ -931,14 +931,89 @@ mod tests {
Ok(())
}

/// This test ensures that a pool is fully drained after opening a short for
/// the absolute maximum amount. It also verifies that the absolute maximum
/// trade returned is always valid.
#[tokio::test]
async fn fuzz_calculate_absolute_max_short() -> Result<()> {
let solvency_tolerance = fixed_u256!(1e9);
let max_iterations = 100;
// Run the fuzz tests
let mut rng = thread_rng();
for _ in 0..*FAST_FUZZ_RUNS {
let state = rng.gen::<State>();
let checkpoint_exposure = {
let value = rng.gen_range(fixed!(0)..=FixedPoint::from(U256::from(U128::MAX)));
if rng.gen() {
-I256::try_from(value)?
} else {
I256::try_from(value)?
}
}.min(I256::try_from(state.long_exposure())?);

// Make sure a short is possible.
if state.effective_share_reserves()?.min(state.share_reserves()) < state.calculate_min_share_reserves(checkpoint_exposure)? {
continue
}
match state.solvency_after_short(state.minimum_transaction_amount(), checkpoint_exposure) {
Ok(_) => (),
Err(_) => continue,
}

// Get the max short.
let absolute_max_short = state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
Some(solvency_tolerance),
Some(max_iterations),
)?;

// The short should be valid.
assert!(absolute_max_short >= state.minimum_transaction_amount());

// Check that the remaining available shares in the pool are below a tolerance.
let solvency = state.solvency_after_short(absolute_max_short, checkpoint_exposure)?;
assert!(solvency <= solvency_tolerance, "solvency={:#?} > solvency_tolerance={:#?}", solvency, solvency_tolerance);

// Get the new state after the trade.
let new_state = state.calculate_pool_state_after_open_short(absolute_max_short, None)?;
let new_zeta = FixedPoint::from(new_state.share_adjustment());

// Absolute max short should have drained the pool's share reserves.
// If zeta is positive, then the effective share reserves should equal the minimum.
if new_zeta > fixed!(0) {
assert!(
new_state.effective_share_reserves()? -
new_state.minimum_share_reserves() <= solvency_tolerance,
"Opening a short for bonds={:#?} should have drained the pool's effective_share_reserves={:#?} to the minimum={:#?}.",
absolute_max_short,
new_state.effective_share_reserves()?,
new_state.minimum_share_reserves(),
);
}
// If zeta is negative, then the share reserves should equal the minimum
else {
assert!(
new_state.share_reserves()-
new_state.minimum_share_reserves() <= solvency_tolerance,
"Opening a short for bonds={:#?} should have drained the pool's share_reserves={:#?} to the minimum={:#?}.",
absolute_max_short,
new_state.share_reserves(),
new_state.minimum_share_reserves(),
);
}
}
Ok(())
}

/// This test differentially fuzzes the `calculate_max_short` function against
/// the Solidity analogue `calculateMaxShort`. `calculateMaxShort` doesn't take
/// a trader's budget into account, so it only provides a subset of
/// `calculate_max_short`'s functionality. With this in mind, we provide
/// `calculate_max_short` with a budget of `U256::MAX` to ensure that the two
/// functions are equivalent.
#[tokio::test]
async fn fuzz_calculate_absolute_max_short() -> Result<()> {
async fn fuzz_sol_calculate_absolute_max_short() -> Result<()> {
// TODO: We should be able to pass these tests with a much lower (if not zero) tolerance.
let sol_correctness_tolerance = fixed!(1e17);

Expand All @@ -961,6 +1036,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
Some(max_iterations),
)
});
Expand Down Expand Up @@ -1079,6 +1155,7 @@ mod tests {
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
None,
)?;

// Bob should always be budget constrained when trying to open the short.
Expand Down Expand Up @@ -1189,6 +1266,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
Some(max_iterations),
)
});
Expand Down Expand Up @@ -1307,6 +1385,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
Some(max_iterations),
)
});
Expand Down
3 changes: 3 additions & 0 deletions crates/hyperdrive-math/src/short/open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ mod tests {
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
None,
)
}) {
Ok(max_bond_amount) => match max_bond_amount {
Expand Down Expand Up @@ -929,6 +930,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
Some(3),
)
}) {
Expand Down Expand Up @@ -1089,6 +1091,7 @@ mod tests {
state.calculate_absolute_max_short(
state.calculate_spot_price_down()?,
checkpoint_exposure,
None,
Some(max_iterations),
)
});
Expand Down

0 comments on commit 0cbfffe

Please sign in to comment.