From cf82bbe36716307a607edc8175038ff7b5320292 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 1 Apr 2024 15:11:41 -0700 Subject: [PATCH] bugfix and docstrings --- crates/hyperdrive-math/src/long/targeted.rs | 351 ++++++++++++-------- crates/test-utils/src/agent.rs | 2 +- 2 files changed, 215 insertions(+), 138 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index dcc2f3c8a..da54e3ac9 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -51,12 +51,18 @@ impl State { // Determine what rate was achieved. let resulting_rate = self.rate_after_long(target_base_delta, Some(target_bond_delta)); // ERROR in here - let abs_rate_error = self.absolute_difference(target_rate, resulting_rate); + // The estimated long should always underestimate because the realized price + // should always be greater than the spot price. + if target_rate > resulting_rate { + return Err(eyre!("get_targeted_long: We overshot the zero-crossing.",)); + } + let rate_error = resulting_rate - target_rate; + // Verify solvency and target rate. if self .solvency_after_long(target_base_delta, target_bond_delta, checkpoint_exposure) .is_some() - && abs_rate_error < allowable_error + && rate_error < allowable_error { return Ok(target_base_delta); } else { @@ -66,7 +72,7 @@ impl State { target_base_delta } else { // overshot; use the minimum amount to be safe - self.minimum_transaction_amount() // TODO: Base or bonds or shares? probably base. + self.minimum_transaction_amount() // in base }; // Iteratively find a solution @@ -76,7 +82,15 @@ impl State { .unwrap(); let resulting_rate = self .rate_after_long(possible_target_base_delta, Some(possible_target_bond_delta)); - let abs_rate_error = self.absolute_difference(target_rate, resulting_rate); + + // We assume that the loss is positive only because Newton's + // method and the one-shot approximation will always underestimate. + if target_rate > resulting_rate { + return Err(eyre!("get_targeted_long: We overshot the zero-crossing.",)); + } + // The loss is $l(x) = r(x) - r_t$ for some rate after a long + // is opened, $r(x)$, and target rate, $r_t$. + let loss = resulting_rate - target_rate; // If we've done it (solvent & within error), then return the value. if self @@ -86,15 +100,18 @@ impl State { checkpoint_exposure, ) .is_some() - && abs_rate_error < allowable_error + && loss < allowable_error { return Ok(possible_target_base_delta); // Otherwise perform another iteration. } else { - let negative_loss_derivative = match self.negative_targeted_loss_derivative( + // The derivative of the loss is $l'(x) = r'(x)$. + // We return $-l'(x)$ because $r'(x)$ is negative, which + // can't be represented with FixedPoint. + let negative_loss_derivative = match self.negative_rate_after_long_derivative( possible_target_base_delta, - Some(possible_target_bond_delta), + possible_target_bond_delta, ) { Some(derivative) => derivative, None => { @@ -103,20 +120,17 @@ impl State { )); } }; - let loss = self.targeted_loss( - target_rate, - possible_target_base_delta, - Some(possible_target_bond_delta), - ); - // adding the negative loss derivative instead of subtracting the loss derivative + // Adding the negative loss derivative instead of subtracting the loss derivative + // ∆x_{n+1} = ∆x_{n} - l / l' + // = ∆x_{n} + l / (-l') possible_target_base_delta = possible_target_base_delta + loss / negative_loss_derivative; } } - // If we hit max iterations and never were within error, check solvency & return. - if self + // Solvency error + if !self .solvency_after_long( possible_target_base_delta, self.calculate_open_long(possible_target_base_delta) @@ -125,27 +139,42 @@ impl State { ) .is_some() { - return Ok(possible_target_base_delta); + return Err(eyre!("Guess in `get_targeted_long` is insolvent.")); + } - // Otherwise we'll return an error. - } else { - return Err(eyre!("Initial guess in `get_targeted_long` is insolvent.")); + // Accuracy error + let possible_target_bond_delta = self + .calculate_open_long(possible_target_base_delta) + .unwrap(); + let resulting_rate = + self.rate_after_long(possible_target_base_delta, Some(possible_target_bond_delta)); + if target_rate > resulting_rate { + return Err(eyre!("get_targeted_long: We overshot the zero-crossing.",)); + } + let loss = resulting_rate - target_rate; + if !(loss < allowable_error) { + return Err(eyre!( + "get_targeted_long: Unable to find an acceptible loss. Final loss = {}.", + loss + )); } - } - } - /// The non-negative difference between two values - /// TODO: Add docs - fn absolute_difference(&self, x: FixedPoint, y: FixedPoint) -> FixedPoint { - if y > x { - y - x - } else { - x - y + Ok(possible_target_base_delta) } } - /// The spot fixed rate after a long has been opened - /// TODO: Add docs + /// The spot fixed rate after a long has been opened. + /// + /// We calculate the rate for a fixed length of time as: + /// $$ + /// r(x) = (1 - p(x)) / (p(x) t) + /// $$ + /// + /// where $p(x)$ is the spot price after a long for `delta_bonds`$= x$ and + /// t is the normalized position druation. + /// + /// In this case, we use the resulting spot price after a hypothetical long + /// for `base_amount` is opened. fn rate_after_long( &self, base_amount: FixedPoint, @@ -157,63 +186,109 @@ impl State { (fixed!(1e18) - resulting_price) / (resulting_price * annualized_time) } - /// The derivative of the equation for calculating the spot rate after a long - /// TODO: Add docs + /// The derivative of the equation for calculating the spot rate after a long. + /// + /// For some $r = (1 - p(x)) / (p(x) * t)$, where $p(x)$ + /// is the spot price after a long of `delta_base`$= x$ was opened and $t$ + /// is the annualized position duration, the rate derivative is: + /// + /// $$ + /// r'(x) = \frac{(-p'(x) p(x) t - (1 - p(x)) (p'(x) t))}{(p(x) t)^2} // + /// r'(x) = \frac{-p'(x)}{t p(x)^2} + /// $$ + /// + /// We return $-r'(x)$ because negative numbers cannot be represented by FixedPoint. fn negative_rate_after_long_derivative( &self, base_amount: FixedPoint, - bond_amount: Option, + bond_amount: FixedPoint, ) -> Option { let annualized_time = self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let price = self.get_spot_price_after_long(base_amount, bond_amount); + let price = self.get_spot_price_after_long(base_amount, Some(bond_amount)); let price_derivative = match self.price_after_long_derivative(base_amount, bond_amount) { Some(derivative) => derivative, None => return None, }; - - // The actual equation we want to solve is: - // (-p' * p * d - (1-p) (p'd + p)) / (p * d)^2 + // The actual equation we want to represent is: + // r' = -p' / (t p^2) // We can do a trick to return a positive-only version and // indicate that it should be negative in the fn name. - // -1 * -1 * (-p' * p * d - (1-p) (p'*d + p)) / (p * d)^2 - // -1 * (p' * p * d + (1-p) (p'*d + p)) / (p * d)^2 - Some( - (price_derivative * price * annualized_time - + (fixed!(1e18) - price) * (price_derivative * annualized_time + price)) - / (price * annualized_time).pow(fixed!(2e18)), - ) + Some(price_derivative / (annualized_time * price.pow(fixed!(2e18)))) } - /// The derivative of the price after a long - /// TODO: Add docs + /// The derivative of the price after a long. + /// + /// The price after a long that moves shares by $\Delta z$ and bonds by $\Delta y$ + /// is equal to $P(\Delta z) = \frac{\mu (z_e + \Delta z)}{y - \Delta y}^T$, + /// where $T$ is the time stretch constant and $z_e$ is the initial effective share reserves. + /// Equivalently, for some amount of `delta_base`$= x$ provided to open a long, + /// we can write: + /// + /// $$ + /// p(x) = \frac{\mu (z_e + \frac{x}{c} - g(x) - \zeta)}{y_0 - Y(x)}^{T} + /// $$ + /// where $g(x)$ is the [open_long_governance_fee](long::fees::open_long_governance_fee), + /// $Y(x)$ is the [long_amount](long::open::calculate_open_long), and $\zeta$ is the + /// zeta adjustment. + /// + /// To compute the derivative, we first define some auxiliary variables: + /// $$ + /// a(x) = \mu (z_e + \frac{x}{c} - g(x) - \zeta) \\ + /// b(x) = y_0 - Y(x) \\ + /// v(x) = \frac{a(x)}{b(x)} + /// $$ + /// + /// and thus $p(x) = v(x)^T$. Given these, we can write out intermediate derivatives: + /// + /// $$ + /// a'(x) = \frac{\mu}{c} - g'(x) \\ + /// b'(x) = -Y'(x) \\ + /// v'(x) = \frac{b a' - a b'}{b^2} + /// $$ + /// + /// And finally, the price after long derivative is: + /// + /// $$ + /// p'(x) = v'(x) T v(x)^(T-1) + /// $$ + /// fn price_after_long_derivative( &self, base_amount: FixedPoint, - bond_amount: Option, + bond_amount: FixedPoint, ) -> Option { - let bond_amount = match bond_amount { - Some(bond_amount) => bond_amount, - None => self.calculate_open_long(base_amount).unwrap(), - }; - let long_amount_derivative = match self.long_amount_derivative(base_amount) { - Some(derivative) => derivative, - None => return None, - }; - let initial_spot_price = self.get_spot_price(); + // g'(x) let gov_fee_derivative = - self.governance_lp_fee() * self.curve_fee() * (fixed!(1e18) - initial_spot_price); + self.governance_lp_fee() * self.curve_fee() * (fixed!(1e18) - self.get_spot_price()); + + // a(x) = u (z_e + x/c - g(x) - zeta) let inner_numerator = self.mu() * (self.ze() + base_amount / self.vault_share_price() - self.open_long_governance_fee(base_amount) - self.zeta().into()); + + // a'(x) = u / c - g'(x) let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; + + // b(x) = y_0 - Y(x) let inner_denominator = self.bond_reserves() - bond_amount; + // b'(x) = Y'(x) + let long_amount_derivative = match self.long_amount_derivative(base_amount) { + Some(derivative) => derivative, + None => return None, + }; + + // v(x) = a(x) / b(x) + // v'(x) = ( b(x) * a'(x) + a(x) * b'(x) ) / b(x)^2 let inner_derivative = (inner_denominator * inner_numerator_derivative + inner_numerator * long_amount_derivative) / inner_denominator.pow(fixed!(2e18)); - // Second quotient is flipped (denominator / numerator) to avoid negative exponent + + // p'(x) = v'(x) T v(x)^(T-1) + // p'(x) = v'(x) T v(x)^(-1)^(1-T) + // v(x) is flipped to (denominator / numerator) to avoid a negative exponent return Some( inner_derivative * self.time_stretch() @@ -221,91 +296,86 @@ impl State { ); } - /// The loss used for the targeted long optimization process - /// TODO: Add docs - fn targeted_loss( - &self, - target_rate: FixedPoint, - base_amount: FixedPoint, - bond_amount: Option, - ) -> FixedPoint { - let resulting_rate = self.rate_after_long(base_amount, bond_amount); - // This should never happen, but jic - if target_rate > resulting_rate { - panic!("We overshot the zero-crossing!"); - } - resulting_rate - target_rate - } - - /// Derivative of the targeted long loss - /// TODO: Add docs - fn negative_targeted_loss_derivative( - &self, - base_amount: FixedPoint, - bond_amount: Option, - ) -> Option { - match self.negative_rate_after_long_derivative(base_amount, bond_amount) { - Some(derivative) => return Some(derivative), - None => return None, - } - } - /// Calculate the base & bond deltas from the current state given desired new reserve levels - /// TODO: Add docs + /// + /// Given a target ending pool share reserves, $z_t$, and bond reserves, $y_t$, + /// the trade deltas to achieve that state would be: + /// + /// $$ + /// \Delta x = c * (z_t - z_e) \\ + /// \Delta y = y - y_t - c(x) + /// $$ + /// + /// where c(x) is the (open_long_curve_fee)[long::fees::open_long_curve_fees]. fn trade_deltas_from_reserves( &self, share_reserves: FixedPoint, bond_reserves: FixedPoint, ) -> (FixedPoint, FixedPoint) { - // The spot max base amount is given by: - // - // spot_target_base_amount = c * (z_t - z) let base_delta = (share_reserves - self.effective_share_reserves()) * self.vault_share_price(); - - // The spot max bond amount is given by: - // - // spot_target_bond_amount = (y - y_t) - c(x) let bond_delta = (self.bond_reserves() - bond_reserves) - self.open_long_curve_fees(base_delta); - (base_delta, bond_delta) } /// Calculates the long that should be opened to hit a target interest rate. - /// This calculation does not take Hyperdrive's solvency constraints into account and shouldn't be used directly. + /// This calculation does not take Hyperdrive's solvency constraints into + /// account and shouldn't be used directly. + /// + /// The price for a given fixed-rate is given by $p = 1 / (r t + 1)$, where + /// $r$ is the fixed-rate and $t$ is the annualized position duration. The + /// price for a given pool reserves is given by $p = \frac{\mu z}{y}^T$, + /// where $\mu$ is the initial share price and $T$ is the time stretch + /// constant. By setting these equal we can solve for the pool reserve levels + /// as a function of a target rate. + /// + /// For some target rate, $r_t$, the pool share reserves, $z_t$, must be: + /// + /// $$ + /// z_t &= \frac{1}{\mu} \left( + /// \frac{k}{\frac{c}{\mu} + \left( + /// (r_t t + 1)^{\frac{1}{T}} + /// \right)^{1 - T}} + /// \right)^{\tfrac{1}{1 - T}} + /// $$ + /// + /// and the pool bond reserves, $y_t$, must be: + /// + /// $$ + /// y_t = \left( + /// \frac{k}{ \frac{c}{\mu} + \left( + /// \left( r_t t + 1 \right)^{\frac{1}{T}} + /// \right)^{1-T}} + /// \right)^{1-T} \left( r_t t + 1 \right)^{\frac{1}{T}} + /// $$ fn reserves_given_rate_ignoring_exposure>( &self, target_rate: F, ) -> (FixedPoint, FixedPoint) { - // - // TODO: Docstring - // let target_rate = target_rate.into(); let annualized_time = self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); + + // First get the target share reserves let c_over_mu = self .vault_share_price() .div_up(self.initial_vault_share_price()); let scaled_rate = (target_rate.mul_up(annualized_time) + fixed!(1e18)) .pow(fixed!(1e18) / self.time_stretch()); - let inner = (self.k_down() + let target_base_reserves = (self.k_down() / (c_over_mu + scaled_rate.pow(fixed!(1e18) - self.time_stretch()))) .pow(fixed!(1e18) / (fixed!(1e18) - self.time_stretch())); - let target_share_reserves = inner / self.initial_vault_share_price(); + let target_share_reserves = target_base_reserves / self.initial_vault_share_price(); // Now that we have the target share reserves, we can calculate the - // target bond reserves using the formula: - // - // TODO: docstring - // - let target_bond_reserves = inner * scaled_rate; + // target bond reserves. + let target_bond_reserves = target_base_reserves * scaled_rate; (target_share_reserves, target_bond_reserves) } } -// TODO: Modify this test to use mock for state updates #[cfg(test)] mod tests { use eyre::Result; @@ -313,18 +383,12 @@ mod tests { use test_utils::{ agent::Agent, chain::{Chain, TestChain}, - constants::FAST_FUZZ_RUNS, + constants::FUZZ_RUNS, }; use tracing_test::traced_test; use super::*; - // TODO: - // #[traced_test] - // #[tokio::test] - // async fn test_reserves_given_rate_ignoring_solvency() -> Result<()> { - // } - #[traced_test] #[tokio::test] async fn test_get_targeted_long_with_budget() -> Result<()> { @@ -336,8 +400,8 @@ mod tests { let allowable_solvency_error = fixed!(1e5); let allowable_budget_error = fixed!(1e5); - let allowable_rate_error = fixed!(1e14); - let num_newton_iters = 7; + let allowable_rate_error = fixed!(1e10); + let num_newton_iters = 3; // Initialize a test chain; don't need mocks because we want state updates. let chain = TestChain::new(2).await?; @@ -353,7 +417,7 @@ mod tests { // Fuzz test let mut rng = thread_rng(); - for _ in 0..*FAST_FUZZ_RUNS { + for _ in 0..*FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; @@ -398,34 +462,47 @@ mod tests { // 1. The pool's spot price is under the max spot price prior to // considering fees // 2. The pool's solvency is above zero. - // 3. IF Bob's budget is not consumed; then new rate is the target rate + // 3. IF Bob's budget is not consumed; then new rate is close to the target rate + + // Check that our resulting price is under the max let spot_price_after_long = bob.get_state().await?.get_spot_price(); - let is_under_max_price = max_spot_price_before_long > spot_price_after_long; + assert!( + max_spot_price_before_long > spot_price_after_long, + "Resulting price is greater than the max." + ); + + // Check solvency let is_solvent = { let state = bob.get_state().await?; state.get_solvency() > allowable_solvency_error }; - assert!( - is_under_max_price, - "Invalid targeted long: Resulting price is greater than the max." - ); - assert!( - is_solvent, - "Invalid targeted long: Resulting pool state is not solvent." - ); + assert!(is_solvent, "Resulting pool state is not solvent."); + // If the budget was NOT consumed, then we assume the target was hit. let new_rate = bob.get_state().await?.get_spot_rate(); - let is_budget_consumed = bob.base() < allowable_budget_error; - let is_rate_achieved = if new_rate > target_rate { - new_rate - target_rate < allowable_rate_error + if !(bob.base() <= allowable_budget_error) { + // Actual price might result in long overshooting the target. + let abs_error = if target_rate > new_rate { + target_rate - new_rate + } else { + new_rate - target_rate + }; + assert!( + abs_error <= allowable_rate_error, + "target_rate was {}, realized rate is {}. abs_error={} was not <= {}.", + target_rate, + new_rate, + abs_error, + allowable_rate_error + ); + + // else, we should have undershot } else { - target_rate - new_rate < allowable_rate_error - }; - if !is_budget_consumed { assert!( - is_rate_achieved, - "Invalid targeted long: target_rate was {}, realized rate is {}.", - target_rate, new_rate + new_rate < target_rate, + "The new_rate={} should be < target_rate={} when budget constrained.", + new_rate, + target_rate ); } diff --git a/crates/test-utils/src/agent.rs b/crates/test-utils/src/agent.rs index 01ecbdc6b..d36bb62eb 100644 --- a/crates/test-utils/src/agent.rs +++ b/crates/test-utils/src/agent.rs @@ -4,7 +4,7 @@ use ethers::{ abi::Detokenize, contract::ContractCall, prelude::EthLogDecode, - providers::{maybe, Http, Middleware, Provider, RetryClient}, + providers::{Http, Middleware, Provider, RetryClient}, types::{Address, BlockId, I256, U256}, }; use eyre::Result;