From e5be7e2abfbe36358b1db8268a040e623d600244 Mon Sep 17 00:00:00 2001 From: Dylan Date: Fri, 8 Mar 2024 09:40:51 +0900 Subject: [PATCH 01/12] initial commit for targeted long --- crates/hyperdrive-math/src/long.rs | 1 + crates/hyperdrive-math/src/long/max.rs | 6 +- crates/hyperdrive-math/src/long/targeted.rs | 233 ++++++++++++++++++++ crates/test-utils/src/agent.rs | 19 ++ 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 crates/hyperdrive-math/src/long/targeted.rs diff --git a/crates/hyperdrive-math/src/long.rs b/crates/hyperdrive-math/src/long.rs index 189b5fc44..e2e7f63ae 100644 --- a/crates/hyperdrive-math/src/long.rs +++ b/crates/hyperdrive-math/src/long.rs @@ -2,3 +2,4 @@ mod close; mod fees; mod max; mod open; +mod targeted; diff --git a/crates/hyperdrive-math/src/long/max.rs b/crates/hyperdrive-math/src/long/max.rs index c66222aad..3ee473df8 100644 --- a/crates/hyperdrive-math/src/long/max.rs +++ b/crates/hyperdrive-math/src/long/max.rs @@ -340,7 +340,7 @@ impl State { /// It's possible that the pool is insolvent after opening a long. In this /// case, we return `None` since the fixed point library can't represent /// negative numbers. - fn solvency_after_long( + pub fn solvency_after_long( &self, base_amount: FixedPoint, bond_amount: FixedPoint, @@ -380,7 +380,7 @@ impl State { /// This derivative is negative since solvency decreases as more longs are /// opened. We use the negation of the derivative to stay in the positive /// domain, which allows us to use the fixed point library. - fn solvency_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + pub fn solvency_after_long_derivative(&self, base_amount: FixedPoint) -> Option { let maybe_derivative = self.long_amount_derivative(base_amount); maybe_derivative.map(|derivative| { (derivative @@ -419,7 +419,7 @@ impl State { /// $$ /// c'(x) = \phi_{c} \cdot \left( \tfrac{1}{p} - 1 \right) /// $$ - fn long_amount_derivative(&self, base_amount: FixedPoint) -> Option { + pub fn long_amount_derivative(&self, base_amount: FixedPoint) -> Option { let share_amount = base_amount / self.vault_share_price(); let inner = self.initial_vault_share_price() * (self.effective_share_reserves() + share_amount); diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs new file mode 100644 index 000000000..4930bb28f --- /dev/null +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -0,0 +1,233 @@ +use ethers::{providers::maybe, types::I256}; +use fixed_point::FixedPoint; +use fixed_point_macros::fixed; + +use crate::{State, YieldSpace}; + +impl State { + /// Gets a target long that can be opened given a budget to achieve a desired fixed rate. + pub fn get_targeted_long, I: Into>( + &self, + budget: F, + target_rate: F, + checkpoint_exposure: I, + maybe_max_iterations: Option, + ) -> FixedPoint { + let budget = budget.into(); + let target_rate = target_rate.into(); + let checkpoint_exposure = checkpoint_exposure.into(); + + // Estimate the long that achieves a target rate + let (absolute_target_base_amount, absolute_target_bond_amount) = + self.absolute_targeted_long(target_rate); + // Get the maximum long that brings the spot price to 1. + let max_base_amount = self.get_max_long(budget, checkpoint_exposure, maybe_max_iterations); + // Ensure that the target is less than the max. + let target_base_amount = absolute_target_base_amount.min(max_base_amount); + // Verify solvency. + if self + .solvency_after_long( + absolute_target_base_amount, + absolute_target_bond_amount, + checkpoint_exposure, + ) + .is_some() + { + return target_base_amount.min(budget); + } else { + // TODO: Refine using an iterative method + panic!("Initial guess in `get_targeted_long` is insolvent."); + } + } + + /// 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. + fn absolute_targeted_long>( + &self, + target_rate: F, + ) -> (FixedPoint, FixedPoint) { + // + // TODO: Docstring + // + let target_rate = target_rate.into(); + let c_over_mu = self + .vault_share_price() + .div_up(self.initial_vault_share_price()); + let scaled_rate = (target_rate.mul_up(self.position_duration()) + fixed!(1e18)) + .pow(fixed!(1e18) / self.time_stretch()); + let inner = (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(); + + // 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; + + // The absolute max base amount is given by: + // + // absolute_target_base_amount = c * (z_t - z) + let absolute_target_base_amount = + (target_share_reserves - self.effective_share_reserves()) * self.vault_share_price(); + + // The absolute max bond amount is given by: + // + // absolute_target_bond_amount = (y - y_t) - c(x) + let absolute_target_bond_amount = (self.bond_reserves() - target_bond_reserves) + - self.open_long_curve_fees(absolute_target_base_amount); + + (absolute_target_base_amount, absolute_target_bond_amount) + } +} + +#[cfg(test)] +mod tests { + use eyre::Result; + use rand::{thread_rng, Rng}; + use test_utils::{ + agent::Agent, + chain::{Chain, TestChain}, + constants::FUZZ_RUNS, + }; + use tracing_test::traced_test; + + use super::*; + + #[traced_test] + #[tokio::test] + async fn test_get_targeted_long() -> Result<()> { + // Spawn a test chain and create three agents -- Alice, Bob, and Claire. Alice + // is funded with a large amount of capital so that she can initialize + // the pool. Bob is funded with a small amount of capital so that we + // can test `get_targeted_long` when budget is the primary constraint. + // Claire is funded with a large amount of capital so tha we can test + // `get_targeted_long` when budget is not a constraint. + let mut rng = thread_rng(); + let chain = TestChain::new(3).await?; + let (alice, bob, claire) = ( + chain.accounts()[0].clone(), + chain.accounts()[1].clone(), + chain.accounts()[2].clone(), + ); + let mut alice = + Agent::new(chain.client(alice).await?, chain.addresses().clone(), None).await?; + let mut bob = Agent::new(chain.client(bob).await?, chain.addresses(), None).await?; + let mut claire = Agent::new(chain.client(claire).await?, chain.addresses(), None).await?; + let config = bob.get_config().clone(); + + for _ in 0..*FUZZ_RUNS { + // Snapshot the chain. + let id = chain.snapshot().await?; + + // Fund Alice and Bob. + let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); + let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18)); + let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18)); + alice.fund(contribution).await?; // large budget for initializing the pool + bob.fund(budget).await?; // small budget for resource-constrained targeted longs + claire.fund(contribution).await?; // large budget for unconstrained targeted longs + + // Alice initializes the pool. + alice.initialize(fixed_rate, contribution, None).await?; + + // Some of the checkpoint passes and variable interest accrues. + alice + .checkpoint(alice.latest_checkpoint().await?, None) + .await?; + let rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18)); + alice + .advance_time( + rate, + FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18), + ) + .await?; + + // Bob opens a targeted long. + let max_spot_price = bob.get_state().await?.get_max_spot_price(); + let target_rate = fixed_rate - fixed!(1e18); // Bob can't afford this rate + let targeted_long = bob.get_targeted_long(target_rate, None).await?; + let spot_price_after_long = bob + .get_state() + .await? + .get_spot_price_after_long(targeted_long); + bob.open_long(targeted_long, None, None).await?; + + // Three things should be true after opening the long: + // + // 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. Bob's budget is consumed. + let is_under_max_price = max_spot_price > spot_price_after_long; + let is_solvent = { + let state = bob.get_state().await?; + let error_tolerance = fixed!(1e5); + state.get_solvency() > error_tolerance + }; + let is_budget_consumed = { + let error_tolerance = fixed!(1e5); + bob.base() < error_tolerance + }; + assert!( + is_under_max_price && is_solvent && is_budget_consumed, + "Invalid targeted long." + ); + + // Claire opens a targeted long. + let max_spot_price = claire.get_state().await?.get_max_spot_price(); + let target_rate = fixed_rate - fixed!(0.1e18); // Claire can afford this rate + let targeted_long = claire.get_targeted_long(target_rate, None).await?; + let spot_price_after_long = claire + .get_state() + .await? + .get_spot_price_after_long(targeted_long); + claire.open_long(targeted_long, None, None).await?; + + // Four things should be true after opening the long: + // + // 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. Claire's budget is not consumed. + // 4. The spot rate is close to the target rate + let is_under_max_price = max_spot_price > spot_price_after_long; + let is_solvent = { + let state = claire.get_state().await?; + let error_tolerance = fixed!(1e5); + state.get_solvency() > error_tolerance + }; + let is_budget_consumed = { + let error_tolerance = fixed!(1e18); + claire.base() > error_tolerance + }; + let does_target_match_spot_rate = { + let state = claire.get_state().await?; + let fixed_rate = state.get_spot_rate(); + let error_tolerance = fixed!(1e18); + if fixed_rate > target_rate { + fixed_rate - target_rate < error_tolerance + } else { + target_rate - fixed_rate < error_tolerance + } + }; + assert!( + is_under_max_price + && is_solvent + && is_budget_consumed + && does_target_match_spot_rate, + "Invalid targeted long." + ); + + // Revert to the snapshot and reset the agent's wallets. + chain.revert(id).await?; + alice.reset(Default::default()); + bob.reset(Default::default()); + claire.reset(Default::default()); + } + + Ok(()) + } +} diff --git a/crates/test-utils/src/agent.rs b/crates/test-utils/src/agent.rs index 5ab16c506..1f60b4bbe 100644 --- a/crates/test-utils/src/agent.rs +++ b/crates/test-utils/src/agent.rs @@ -968,6 +968,25 @@ impl Agent { Ok(state.calculate_max_long(self.wallet.base, checkpoint_exposure, maybe_max_iterations)) } + /// Gets the long that moves the fixed rate to a target value. + pub async fn get_targeted_long( + &self, + target_rate: FixedPoint, + maybe_max_iterations: Option, + ) -> Result { + let state = self.get_state().await?; + let checkpoint_exposure = self + .hyperdrive + .get_checkpoint_exposure(state.to_checkpoint(self.now().await?)) + .await?; + Ok(state.get_targeted_long( + self.wallet.base, + target_rate, + checkpoint_exposure, + maybe_max_iterations, + )) + } + /// Calculates the max short that can be opened in the current checkpoint. /// /// Since interest can accrue between the time the calculation is made and From 6abc8041b55e06ef3ef6ad1ccdf39a0cb04a97e5 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 26 Mar 2024 15:06:12 -0700 Subject: [PATCH 02/12] first pass at iterative approach --- crates/hyperdrive-math/src/long/targeted.rs | 165 +++++++++++++++++--- 1 file changed, 140 insertions(+), 25 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 4930bb28f..d10d823e6 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -1,4 +1,7 @@ -use ethers::{providers::maybe, types::I256}; +use ethers::{ + providers::maybe, + types::{I256, U256}, +}; use fixed_point::FixedPoint; use fixed_point_macros::fixed; @@ -18,34 +21,146 @@ impl State { let checkpoint_exposure = checkpoint_exposure.into(); // Estimate the long that achieves a target rate - let (absolute_target_base_amount, absolute_target_bond_amount) = - self.absolute_targeted_long(target_rate); - // Get the maximum long that brings the spot price to 1. - let max_base_amount = self.get_max_long(budget, checkpoint_exposure, maybe_max_iterations); - // Ensure that the target is less than the max. - let target_base_amount = absolute_target_base_amount.min(max_base_amount); - // Verify solvency. + let (spot_target_base_amount, spot_target_bond_amount) = + self.spot_targeted_long(target_rate); + let resulting_rate = self.rate_after_long(spot_target_base_amount); + let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); + // TODO: ask alex about an appropriate tolerance for the rate error + let allowable_error = fixed!(1e5); + // Verify solvency and target rate if self .solvency_after_long( - absolute_target_base_amount, - absolute_target_bond_amount, + spot_target_base_amount, + spot_target_bond_amount, checkpoint_exposure, ) .is_some() + && abs_rate_error < allowable_error { - return target_base_amount.min(budget); + return spot_target_base_amount.min(budget); } else { - // TODO: Refine using an iterative method - panic!("Initial guess in `get_targeted_long` is insolvent."); + // Adjust your first guess + // TODO: Need to come up with a smarter and safe first guess + let mut possible_target_base_amount = if resulting_rate > target_rate { + spot_target_base_amount / fixed!(15e17) // overshot; need a smaller long + } else { + spot_target_base_amount * fixed!(15e17) // undershot; need a larger long + }; + // Iteratively find a solution + for _ in 0..maybe_max_iterations.unwrap_or(7) { + let possible_target_bond_amount = + self.calculate_open_long(possible_target_base_amount); + let resulting_rate = self.rate_after_long(possible_target_base_amount); + let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); + if self + .solvency_after_long( + possible_target_base_amount, + possible_target_bond_amount, + checkpoint_exposure, + ) + .is_some() + && abs_rate_error < allowable_error + { + return possible_target_base_amount.max(budget); + } else { + possible_target_base_amount = possible_target_base_amount + + self.targeted_loss_derivative( + target_rate, + possible_target_base_amount, + possible_target_bond_amount, + ); + } + } + if self + .solvency_after_long( + possible_target_base_amount, + self.calculate_open_long(possible_target_base_amount), + checkpoint_exposure, + ) + .is_some() + { + return possible_target_base_amount.max(budget); + } else { + panic!("Initial guess in `get_targeted_long` is insolvent."); + } } } + /// The non-negative error between a target rate and resulting rate + /// TODO: Add docs + fn abs_rate_error(&self, target_rate: FixedPoint, resulting_rate: FixedPoint) -> FixedPoint { + if resulting_rate > target_rate { + resulting_rate - target_rate + } else { + target_rate - resulting_rate + } + } + + /// The spot fixed rate after a long has been opened + /// TODO: Add docs + fn rate_after_long(&self, base_amount: FixedPoint) -> FixedPoint { + let annualized_time = + self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); + let resulting_price = self.get_spot_price_after_long(base_amount); + (fixed!(1e18) - resulting_price) / (resulting_price * annualized_time) + } + + /// The derivative of the equation for calculating the spot rate after a long + /// TODO: Add docs + fn rate_after_long_derivative( + &self, + base_amount: FixedPoint, + bond_amount: FixedPoint, + ) -> FixedPoint { + let annualized_time = + self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); + let dprice = self.price_after_long_derivative(base_amount, bond_amount); + // TODO: This is the price before fees; we want price after fees + let price = self.get_spot_price_after_long(base_amount); + (-dprice * price * annualized_time + - (fixed!(1e18) - price) * (dprice * annualized_time + price)) + / (price * annualized_time).pow(fixed!(2e18)) + } + + /// The derivative of the price after a long + /// TODO: This is wrong -- need to use formula for price after trade not before + /// TODO: Add docs + fn price_after_long_derivative( + &self, + base_amount: FixedPoint, + bond_amount: FixedPoint, + ) -> Option { + let price_after_long = self.get_spot_price_after_long(base_amount); + let maybe_derivative = self.long_amount_derivative(base_amount); + maybe_derivative.map(|derivative| { + -(fixed!(1e18) / price_after_long.pow(fixed!(2e18))) + * ((base_amount * derivative - bond_amount) / base_amount.pow(fixed!(2e18))) + }) + } + + /// The loss used for the targeted long optimization process + /// TODO: Add docs + fn targeted_loss(&self, target_rate: FixedPoint, base_amount: FixedPoint) -> FixedPoint { + let resulting_rate = self.rate_after_long(base_amount); + let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); + (fixed!(1e18) / fixed!(2e18)) * (abs_rate_error).pow(fixed!(2e18)) + } + + /// Derivative of the targeted long loss + /// TODO: Add docs + fn targeted_loss_derivative( + &self, + target_rate: FixedPoint, + base_amount: FixedPoint, + bond_amount: FixedPoint, + ) -> FixedPoint { + (self.rate_after_long(base_amount) - target_rate) + * self.rate_after_long_derivative(base_amount, bond_amount) + } + /// 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. - fn absolute_targeted_long>( - &self, - target_rate: F, - ) -> (FixedPoint, FixedPoint) { + fn spot_targeted_long>(&self, target_rate: F) -> (FixedPoint, FixedPoint) { // // TODO: Docstring // @@ -67,19 +182,19 @@ impl State { // let target_bond_reserves = inner * scaled_rate; - // The absolute max base amount is given by: + // The spot max base amount is given by: // - // absolute_target_base_amount = c * (z_t - z) - let absolute_target_base_amount = + // spot_target_base_amount = c * (z_t - z) + let spot_target_base_amount = (target_share_reserves - self.effective_share_reserves()) * self.vault_share_price(); - // The absolute max bond amount is given by: + // The spot max bond amount is given by: // - // absolute_target_bond_amount = (y - y_t) - c(x) - let absolute_target_bond_amount = (self.bond_reserves() - target_bond_reserves) - - self.open_long_curve_fees(absolute_target_base_amount); + // spot_target_bond_amount = (y - y_t) - c(x) + let spot_target_bond_amount = (self.bond_reserves() - target_bond_reserves) + - self.open_long_curve_fees(spot_target_base_amount); - (absolute_target_base_amount, absolute_target_bond_amount) + (spot_target_base_amount, spot_target_bond_amount) } } From daac125242921d1a8c8a731d4e7f10a5c4050b42 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 28 Mar 2024 12:36:36 -0700 Subject: [PATCH 03/12] update price derivative --- crates/hyperdrive-math/src/long/targeted.rs | 64 +++++++++++---------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index d10d823e6..a05911509 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -64,11 +64,7 @@ impl State { return possible_target_base_amount.max(budget); } else { possible_target_base_amount = possible_target_base_amount - + self.targeted_loss_derivative( - target_rate, - possible_target_base_amount, - possible_target_bond_amount, - ); + + self.targeted_loss_derivative(target_rate, possible_target_base_amount); } } if self @@ -107,35 +103,46 @@ impl State { /// The derivative of the equation for calculating the spot rate after a long /// TODO: Add docs - fn rate_after_long_derivative( - &self, - base_amount: FixedPoint, - bond_amount: FixedPoint, - ) -> FixedPoint { + fn rate_after_long_derivative(&self, base_amount: FixedPoint) -> Option { let annualized_time = self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let dprice = self.price_after_long_derivative(base_amount, bond_amount); - // TODO: This is the price before fees; we want price after fees let price = self.get_spot_price_after_long(base_amount); - (-dprice * price * annualized_time - - (fixed!(1e18) - price) * (dprice * annualized_time + price)) - / (price * annualized_time).pow(fixed!(2e18)) + let price_derivative = match self.price_after_long_derivative(base_amount) { + Some(derivative) => derivative, + None => return None, + }; + Some( + (-price_derivative * price * annualized_time + - (fixed!(1e18) - price) * (price_derivative * annualized_time + price),) + / (price * annualized_time).pow(fixed!(2e18)), + ) } /// The derivative of the price after a long - /// TODO: This is wrong -- need to use formula for price after trade not before /// TODO: Add docs - fn price_after_long_derivative( - &self, - base_amount: FixedPoint, - bond_amount: FixedPoint, - ) -> Option { - let price_after_long = self.get_spot_price_after_long(base_amount); - let maybe_derivative = self.long_amount_derivative(base_amount); - maybe_derivative.map(|derivative| { - -(fixed!(1e18) / price_after_long.pow(fixed!(2e18))) - * ((base_amount * derivative - bond_amount) / base_amount.pow(fixed!(2e18))) - }) + fn price_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + 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(); + let gov_fee_derivative = + self.governance_lp_fee() * self.curve_fee() * (fixed!(1e18) - initial_spot_price); + let inner_numerator = self.mu() + * (self.ze() + base_amount / self.vault_share_price() + - self.open_long_governance_fee(base_amount) + - self.zeta().into()); + let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; + let inner_denominator = self.bond_reserves() - self.calculate_open_long(base_amount); + let inner_denominator_derivative = -long_amount_derivative; + let inner_derivative = (inner_denominator * inner_numerator_derivative + - inner_numerator * inner_denominator_derivative) + / inner_denominator.pow(fixed!(2e18)); + return Some( + inner_derivative + * self.time_stretch() + * (inner_numerator / inner_denominator).pow(self.time_stretch() - fixed!(1e18)), + ); } /// The loss used for the targeted long optimization process @@ -152,10 +159,9 @@ impl State { &self, target_rate: FixedPoint, base_amount: FixedPoint, - bond_amount: FixedPoint, ) -> FixedPoint { (self.rate_after_long(base_amount) - target_rate) - * self.rate_after_long_derivative(base_amount, bond_amount) + * self.rate_after_long_derivative(base_amount) } /// Calculates the long that should be opened to hit a target interest rate. From 4cc371905dcccb6dec924b07b15bdec35d2da515 Mon Sep 17 00:00:00 2001 From: Dylan Date: Fri, 29 Mar 2024 14:57:41 -0700 Subject: [PATCH 04/12] fix optional & required args; switch back to newton's method --- crates/hyperdrive-math/src/long/targeted.rs | 97 ++++++++++++++------- 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index a05911509..32735c88b 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -1,7 +1,7 @@ -use ethers::{ - providers::maybe, - types::{I256, U256}, -}; +use std::result; + +use ethers::types::{I256, U256}; +use eyre::{eyre, Result}; use fixed_point::FixedPoint; use fixed_point_macros::fixed; @@ -15,7 +15,7 @@ impl State { target_rate: F, checkpoint_exposure: I, maybe_max_iterations: Option, - ) -> FixedPoint { + ) -> Result { let budget = budget.into(); let target_rate = target_rate.into(); let checkpoint_exposure = checkpoint_exposure.into(); @@ -37,21 +37,26 @@ impl State { .is_some() && abs_rate_error < allowable_error { - return spot_target_base_amount.min(budget); + return Ok(spot_target_base_amount.min(budget)); } else { // Adjust your first guess - // TODO: Need to come up with a smarter and safe first guess - let mut possible_target_base_amount = if resulting_rate > target_rate { - spot_target_base_amount / fixed!(15e17) // overshot; need a smaller long - } else { - spot_target_base_amount * fixed!(15e17) // undershot; need a larger long - }; + let mut possible_target_base_amount = self.ze() - self.minimum_share_reserves(); + // // TODO: Need to come up with a smarter and safe first guess + // let mut possible_target_base_amount = if resulting_rate > target_rate { + // spot_target_base_amount / fixed!(15e17) // overshot; need a smaller long + // } else { + // spot_target_base_amount * fixed!(15e17) // undershot; need a larger long + // }; // Iteratively find a solution for _ in 0..maybe_max_iterations.unwrap_or(7) { let possible_target_bond_amount = self.calculate_open_long(possible_target_base_amount); + // TODO: make optional bond amount all the way down (through calc_spot_price_after_long) to avoid + // extra `calculate_open_long` let resulting_rate = self.rate_after_long(possible_target_base_amount); let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); + + // If we've done it (solvent & within error), then return the value. if self .solvency_after_long( possible_target_base_amount, @@ -61,12 +66,29 @@ impl State { .is_some() && abs_rate_error < allowable_error { - return possible_target_base_amount.max(budget); + return Ok(possible_target_base_amount.min(budget)); + + // Otherwise perform another iteration. } else { - possible_target_base_amount = possible_target_base_amount - + self.targeted_loss_derivative(target_rate, possible_target_base_amount); + let negative_loss_derivative = match self + .negative_targeted_loss_derivative(possible_target_base_amount) + { + Some(derivative) => derivative, + None => { + return Err(eyre!( + "get_targeted_long: Invalid value when calculating targeted loss derivative.", + )); + } + }; + let loss = self.targeted_loss(target_rate, possible_target_base_amount); + + // adding the negative loss derivative instead of subtracting the loss derivative + possible_target_base_amount = + possible_target_base_amount + loss / negative_loss_derivative; } } + + // If we hit max iterations and never were within error, check solvency & return. if self .solvency_after_long( possible_target_base_amount, @@ -75,9 +97,11 @@ impl State { ) .is_some() { - return possible_target_base_amount.max(budget); + return Ok(possible_target_base_amount.min(budget)); + + // Otherwise we'll return an error. } else { - panic!("Initial guess in `get_targeted_long` is insolvent."); + return Err(eyre!("Initial guess in `get_targeted_long` is insolvent.")); } } } @@ -103,7 +127,7 @@ impl State { /// The derivative of the equation for calculating the spot rate after a long /// TODO: Add docs - fn rate_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + fn negative_rate_after_long_derivative(&self, base_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); @@ -111,9 +135,16 @@ impl State { 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 + // 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_derivative * price * annualized_time + + (fixed!(1e18) - price) * (price_derivative * annualized_time + price)) / (price * annualized_time).pow(fixed!(2e18)), ) } @@ -134,9 +165,9 @@ impl State { - self.zeta().into()); let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; let inner_denominator = self.bond_reserves() - self.calculate_open_long(base_amount); - let inner_denominator_derivative = -long_amount_derivative; + let inner_derivative = (inner_denominator * inner_numerator_derivative - - inner_numerator * inner_denominator_derivative) + + inner_numerator * long_amount_derivative) / inner_denominator.pow(fixed!(2e18)); return Some( inner_derivative @@ -149,19 +180,20 @@ impl State { /// TODO: Add docs fn targeted_loss(&self, target_rate: FixedPoint, base_amount: FixedPoint) -> FixedPoint { let resulting_rate = self.rate_after_long(base_amount); - let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); - (fixed!(1e18) / fixed!(2e18)) * (abs_rate_error).pow(fixed!(2e18)) + // 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 targeted_loss_derivative( - &self, - target_rate: FixedPoint, - base_amount: FixedPoint, - ) -> FixedPoint { - (self.rate_after_long(base_amount) - target_rate) - * self.rate_after_long_derivative(base_amount) + fn negative_targeted_loss_derivative(&self, base_amount: FixedPoint) -> Option { + match self.negative_rate_after_long_derivative(base_amount) { + Some(derivative) => return Some(derivative), + None => return None, + } } /// Calculates the long that should be opened to hit a target interest rate. @@ -204,6 +236,7 @@ impl State { } } +// TODO: Modify this test to use mock for state updates #[cfg(test)] mod tests { use eyre::Result; @@ -224,7 +257,7 @@ mod tests { // is funded with a large amount of capital so that she can initialize // the pool. Bob is funded with a small amount of capital so that we // can test `get_targeted_long` when budget is the primary constraint. - // Claire is funded with a large amount of capital so tha we can test + // Claire is funded with a large amount of capital so that we can test // `get_targeted_long` when budget is not a constraint. let mut rng = thread_rng(); let chain = TestChain::new(3).await?; From 0ecbe0b9736a70d1dcac2dc6c8a5821f483c13be Mon Sep 17 00:00:00 2001 From: Dylan Date: Sat, 30 Mar 2024 16:57:18 -0700 Subject: [PATCH 05/12] update test; adds Result on open_long; bugfix --- crates/hyperdrive-math/src/long/max.rs | 4 +- crates/hyperdrive-math/src/long/open.rs | 12 +- crates/hyperdrive-math/src/long/targeted.rs | 237 +++++++++++--------- crates/test-utils/src/agent.rs | 16 +- 4 files changed, 145 insertions(+), 124 deletions(-) diff --git a/crates/hyperdrive-math/src/long/max.rs b/crates/hyperdrive-math/src/long/max.rs index 3ee473df8..2dc78932b 100644 --- a/crates/hyperdrive-math/src/long/max.rs +++ b/crates/hyperdrive-math/src/long/max.rs @@ -83,7 +83,7 @@ impl State { self.max_long_guess(absolute_max_base_amount, checkpoint_exposure); let mut maybe_solvency = self.solvency_after_long( max_base_amount, - self.calculate_open_long(max_base_amount), + self.calculate_open_long(max_base_amount).unwrap(), checkpoint_exposure, ); if maybe_solvency.is_none() { @@ -121,7 +121,7 @@ impl State { let possible_max_base_amount = max_base_amount + solvency / maybe_derivative.unwrap(); maybe_solvency = self.solvency_after_long( possible_max_base_amount, - self.calculate_open_long(possible_max_base_amount), + self.calculate_open_long(possible_max_base_amount).unwrap(), checkpoint_exposure, ); if let Some(s) = maybe_solvency { diff --git a/crates/hyperdrive-math/src/long/open.rs b/crates/hyperdrive-math/src/long/open.rs index c52278166..3d1e042ce 100644 --- a/crates/hyperdrive-math/src/long/open.rs +++ b/crates/hyperdrive-math/src/long/open.rs @@ -1,3 +1,4 @@ +use eyre::{eyre, Result}; use fixed_point::FixedPoint; use crate::{calculate_rate_given_fixed_price, State, YieldSpace}; @@ -22,7 +23,7 @@ impl State { /// \right) \right)^{1 - t_s} /// \right)^{\tfrac{1}{1 - t_s}} /// $$ - pub fn calculate_open_long>(&self, base_amount: F) -> FixedPoint { + pub fn calculate_open_long>(&self, base_amount: F) -> Result { let base_amount = base_amount.into(); if base_amount < self.config.minimum_transaction_amount.into() { @@ -37,12 +38,15 @@ impl State { let ending_spot_price = self.calculate_spot_price_after_long(base_amount, long_amount.into()); let max_spot_price = self.calculate_max_spot_price(); + println!("ending_spot_price {:#?}", ending_spot_price); + println!("max_spot_price {:#?}", max_spot_price); if ending_spot_price > max_spot_price { - // TODO would be nice to return a `Result` here instead of a panic. - panic!("InsufficientLiquidity: Negative Interest"); + return Err(eyre!( + "calculate_open_long: InsufficientLiquidity: Negative Interest", + )); } - long_amount - self.open_long_curve_fees(base_amount) + Ok(long_amount - self.open_long_curve_fees(base_amount)) } /// Calculates the spot price after opening a Hyperdrive long. diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 32735c88b..63b0f62fe 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -1,5 +1,3 @@ -use std::result; - use ethers::types::{I256, U256}; use eyre::{eyre, Result}; use fixed_point::FixedPoint; @@ -9,7 +7,7 @@ use crate::{State, YieldSpace}; impl State { /// Gets a target long that can be opened given a budget to achieve a desired fixed rate. - pub fn get_targeted_long, I: Into>( + pub fn get_targeted_long_with_budget, I: Into>( &self, budget: F, target_rate: F, @@ -17,27 +15,46 @@ impl State { maybe_max_iterations: Option, ) -> Result { let budget = budget.into(); + match self.get_targeted_long(target_rate, checkpoint_exposure, maybe_max_iterations) { + Ok(long_amount) => Ok(long_amount.min(budget)), + Err(error) => Err(error), + } + } + + /// Gets a target long that can be opened given a desired fixed rate. + pub fn get_targeted_long, I: Into>( + &self, + target_rate: F, + checkpoint_exposure: I, + maybe_max_iterations: Option, + ) -> Result { let target_rate = target_rate.into(); let checkpoint_exposure = checkpoint_exposure.into(); - // Estimate the long that achieves a target rate - let (spot_target_base_amount, spot_target_bond_amount) = - self.spot_targeted_long(target_rate); - let resulting_rate = self.rate_after_long(spot_target_base_amount); + // Estimate the long that achieves a target rate. + let (target_share_reserves, target_bond_reserves) = + self.reserves_given_rate_ignoring_exposure(target_rate); + let (target_base_delta, target_bond_delta) = + self.trade_deltas_from_reserves(target_share_reserves, target_bond_reserves); + println!("spot_target_bond_delta {:#?}", target_bond_delta); + println!("spot_target_base_delta {:#?}", target_base_delta); + + // Determine what rate was achieved. + let resulting_rate = self.rate_after_long(target_base_delta); // ERROR in here + let resulting_price = self.price_for_given_rate(resulting_rate); + println!("resulting_rate {:#?}", resulting_rate); + println!("resulting_price {:#?}", resulting_price); + let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); // TODO: ask alex about an appropriate tolerance for the rate error let allowable_error = fixed!(1e5); - // Verify solvency and target rate + // Verify solvency and target rate. if self - .solvency_after_long( - spot_target_base_amount, - spot_target_bond_amount, - checkpoint_exposure, - ) + .solvency_after_long(target_base_delta, target_bond_delta, checkpoint_exposure) .is_some() && abs_rate_error < allowable_error { - return Ok(spot_target_base_amount.min(budget)); + return Ok(target_base_delta); } else { // Adjust your first guess let mut possible_target_base_amount = self.ze() - self.minimum_share_reserves(); @@ -49,8 +66,9 @@ impl State { // }; // Iteratively find a solution for _ in 0..maybe_max_iterations.unwrap_or(7) { - let possible_target_bond_amount = - self.calculate_open_long(possible_target_base_amount); + let possible_target_bond_amount = self + .calculate_open_long(possible_target_base_amount) + .unwrap(); // TODO: make optional bond amount all the way down (through calc_spot_price_after_long) to avoid // extra `calculate_open_long` let resulting_rate = self.rate_after_long(possible_target_base_amount); @@ -66,7 +84,7 @@ impl State { .is_some() && abs_rate_error < allowable_error { - return Ok(possible_target_base_amount.min(budget)); + return Ok(possible_target_base_amount); // Otherwise perform another iteration. } else { @@ -92,12 +110,13 @@ impl State { if self .solvency_after_long( possible_target_base_amount, - self.calculate_open_long(possible_target_base_amount), + self.calculate_open_long(possible_target_base_amount) + .unwrap(), checkpoint_exposure, ) .is_some() { - return Ok(possible_target_base_amount.min(budget)); + return Ok(possible_target_base_amount); // Otherwise we'll return an error. } else { @@ -106,6 +125,12 @@ impl State { } } + fn price_for_given_rate(&self, rate: FixedPoint) -> FixedPoint { + let annualized_time = + self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); + fixed!(1e18) / (rate * annualized_time + fixed!(1e18)) + } + /// The non-negative error between a target rate and resulting rate /// TODO: Add docs fn abs_rate_error(&self, target_rate: FixedPoint, resulting_rate: FixedPoint) -> FixedPoint { @@ -164,7 +189,8 @@ impl State { - self.open_long_governance_fee(base_amount) - self.zeta().into()); let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; - let inner_denominator = self.bond_reserves() - self.calculate_open_long(base_amount); + let inner_denominator = + self.bond_reserves() - self.calculate_open_long(base_amount).unwrap(); let inner_derivative = (inner_denominator * inner_numerator_derivative + inner_numerator * long_amount_derivative) @@ -196,17 +222,44 @@ impl State { } } + /// Calculate the base & bond deltas from the current state given desired new reserve levels + /// TODO: Add docs + 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. - fn spot_targeted_long>(&self, target_rate: F) -> (FixedPoint, FixedPoint) { + 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)); let c_over_mu = self .vault_share_price() .div_up(self.initial_vault_share_price()); - let scaled_rate = (target_rate.mul_up(self.position_duration()) + fixed!(1e18)) + let scaled_rate = (target_rate.mul_up(annualized_time) + fixed!(1e18)) .pow(fixed!(1e18) / self.time_stretch()); let inner = (self.k_down() / (c_over_mu + scaled_rate.pow(fixed!(1e18) - self.time_stretch()))) @@ -220,19 +273,7 @@ impl State { // let target_bond_reserves = inner * scaled_rate; - // The spot max base amount is given by: - // - // spot_target_base_amount = c * (z_t - z) - let spot_target_base_amount = - (target_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 spot_target_bond_amount = (self.bond_reserves() - target_bond_reserves) - - self.open_long_curve_fees(spot_target_base_amount); - - (spot_target_base_amount, spot_target_bond_amount) + (target_share_reserves, target_bond_reserves) } } @@ -250,63 +291,71 @@ mod tests { 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() -> Result<()> { - // Spawn a test chain and create three agents -- Alice, Bob, and Claire. Alice - // is funded with a large amount of capital so that she can initialize - // the pool. Bob is funded with a small amount of capital so that we - // can test `get_targeted_long` when budget is the primary constraint. - // Claire is funded with a large amount of capital so that we can test - // `get_targeted_long` when budget is not a constraint. - let mut rng = thread_rng(); - let chain = TestChain::new(3).await?; - let (alice, bob, claire) = ( - chain.accounts()[0].clone(), - chain.accounts()[1].clone(), - chain.accounts()[2].clone(), - ); + async fn test_get_targeted_long_with_budget() -> Result<()> { + // Spawn a test chain and create two agents -- Alice and Bob. + // Alice is funded with a large amount of capital so that she can initialize + // the pool. Bob is funded with a random amount of capital so that we + // can test `get_targeted_long` when budget is the primary constraint + // and when it is not. + + // Initialize a test chain; don't need mocks because we want state updates. + let chain = TestChain::new(2).await?; + + // Grab accounts for Alice, Bob, and Claire. + let (alice, bob) = (chain.accounts()[0].clone(), chain.accounts()[1].clone()); + + // Initialize Alice, Bob, and Claire as Agents. let mut alice = Agent::new(chain.client(alice).await?, chain.addresses().clone(), None).await?; let mut bob = Agent::new(chain.client(bob).await?, chain.addresses(), None).await?; - let mut claire = Agent::new(chain.client(claire).await?, chain.addresses(), None).await?; let config = bob.get_config().clone(); + // Fuzz test + let mut rng = thread_rng(); for _ in 0..*FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; // Fund Alice and Bob. - let fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); - let contribution = rng.gen_range(fixed!(10_000e18)..=fixed!(500_000_000e18)); - let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18)); + let contribution = fixed!(1_000_000e18); alice.fund(contribution).await?; // large budget for initializing the pool + let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18)); bob.fund(budget).await?; // small budget for resource-constrained targeted longs - claire.fund(contribution).await?; // large budget for unconstrained targeted longs // Alice initializes the pool. - alice.initialize(fixed_rate, contribution, None).await?; + let initial_fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); + alice + .initialize(initial_fixed_rate, contribution, None) + .await?; + println!("initial state: {:#?}", alice.get_state().await?); + println!("initial_fixed_rate {:#?}", initial_fixed_rate); // Some of the checkpoint passes and variable interest accrues. alice .checkpoint(alice.latest_checkpoint().await?, None) .await?; - let rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18)); + let variable_rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18)); alice .advance_time( - rate, + variable_rate, FixedPoint::from(config.checkpoint_duration) * fixed!(0.5e18), ) .await?; // Bob opens a targeted long. - let max_spot_price = bob.get_state().await?.get_max_spot_price(); - let target_rate = fixed_rate - fixed!(1e18); // Bob can't afford this rate + let max_spot_price_before_long = bob.get_state().await?.get_max_spot_price(); + let target_rate = initial_fixed_rate / fixed!(2e18); + println!("target_rate {:#?}", target_rate); let targeted_long = bob.get_targeted_long(target_rate, None).await?; - let spot_price_after_long = bob - .get_state() - .await? - .get_spot_price_after_long(targeted_long); + println!("targeted_long {:#?}", targeted_long); bob.open_long(targeted_long, None, None).await?; // Three things should be true after opening the long: @@ -314,72 +363,38 @@ 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. Bob's budget is consumed. - let is_under_max_price = max_spot_price > spot_price_after_long; + // 3. IF Bob's budget is not consumed; then new rate is the target rate + 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; let is_solvent = { let state = bob.get_state().await?; let error_tolerance = fixed!(1e5); state.get_solvency() > error_tolerance }; + assert!(is_under_max_price && is_solvent, "Invalid targeted long."); + let is_budget_consumed = { let error_tolerance = fixed!(1e5); bob.base() < error_tolerance }; - assert!( - is_under_max_price && is_solvent && is_budget_consumed, - "Invalid targeted long." - ); - - // Claire opens a targeted long. - let max_spot_price = claire.get_state().await?.get_max_spot_price(); - let target_rate = fixed_rate - fixed!(0.1e18); // Claire can afford this rate - let targeted_long = claire.get_targeted_long(target_rate, None).await?; - let spot_price_after_long = claire - .get_state() - .await? - .get_spot_price_after_long(targeted_long); - claire.open_long(targeted_long, None, None).await?; - - // Four things should be true after opening the long: - // - // 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. Claire's budget is not consumed. - // 4. The spot rate is close to the target rate - let is_under_max_price = max_spot_price > spot_price_after_long; - let is_solvent = { - let state = claire.get_state().await?; + let is_rate_achieved = { + let state = bob.get_state().await?; + let new_rate = state.get_spot_rate(); let error_tolerance = fixed!(1e5); - state.get_solvency() > error_tolerance - }; - let is_budget_consumed = { - let error_tolerance = fixed!(1e18); - claire.base() > error_tolerance - }; - let does_target_match_spot_rate = { - let state = claire.get_state().await?; - let fixed_rate = state.get_spot_rate(); - let error_tolerance = fixed!(1e18); - if fixed_rate > target_rate { - fixed_rate - target_rate < error_tolerance + if new_rate > target_rate { + new_rate - target_rate < error_tolerance } else { - target_rate - fixed_rate < error_tolerance + target_rate - new_rate < error_tolerance } }; - assert!( - is_under_max_price - && is_solvent - && is_budget_consumed - && does_target_match_spot_rate, - "Invalid targeted long." - ); + if !is_budget_consumed { + assert!(is_rate_achieved, "Invalid targeted long."); + } // Revert to the snapshot and reset the agent's wallets. chain.revert(id).await?; alice.reset(Default::default()); bob.reset(Default::default()); - claire.reset(Default::default()); } Ok(()) diff --git a/crates/test-utils/src/agent.rs b/crates/test-utils/src/agent.rs index 1f60b4bbe..95ef0e2e9 100644 --- a/crates/test-utils/src/agent.rs +++ b/crates/test-utils/src/agent.rs @@ -937,7 +937,7 @@ impl Agent { /// with the current market state. pub async fn calculate_open_long(&self, base_amount: FixedPoint) -> Result { let state = self.get_state().await?; - Ok(state.calculate_open_long(base_amount)) + state.calculate_open_long(base_amount) } /// Calculates the deposit required to short a given amount of bonds with the @@ -979,12 +979,14 @@ impl Agent { .hyperdrive .get_checkpoint_exposure(state.to_checkpoint(self.now().await?)) .await?; - Ok(state.get_targeted_long( - self.wallet.base, - target_rate, - checkpoint_exposure, - maybe_max_iterations, - )) + Ok(state + .get_targeted_long_with_budget( + self.wallet.base, + target_rate, + checkpoint_exposure, + maybe_max_iterations, + ) + .unwrap()) } /// Calculates the max short that can be opened in the current checkpoint. From e7ffc391a4fbdfbc5c2a663fb745a5fefd4ea026 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 1 Apr 2024 13:42:26 -0700 Subject: [PATCH 06/12] propagate optional bond_amount to lower fns; fixes --- crates/hyperdrive-math/src/long/open.rs | 16 +- crates/hyperdrive-math/src/long/targeted.rs | 212 ++++++++++++-------- crates/test-utils/src/agent.rs | 4 +- 3 files changed, 135 insertions(+), 97 deletions(-) diff --git a/crates/hyperdrive-math/src/long/open.rs b/crates/hyperdrive-math/src/long/open.rs index 3d1e042ce..cc7cf94cc 100644 --- a/crates/hyperdrive-math/src/long/open.rs +++ b/crates/hyperdrive-math/src/long/open.rs @@ -38,8 +38,6 @@ impl State { let ending_spot_price = self.calculate_spot_price_after_long(base_amount, long_amount.into()); let max_spot_price = self.calculate_max_spot_price(); - println!("ending_spot_price {:#?}", ending_spot_price); - println!("max_spot_price {:#?}", max_spot_price); if ending_spot_price > max_spot_price { return Err(eyre!( "calculate_open_long: InsufficientLiquidity: Negative Interest", @@ -55,17 +53,17 @@ impl State { &self, base_amount: FixedPoint, bond_amount: Option, - ) -> FixedPoint { + ) -> Result { let bond_amount = match bond_amount { Some(bond_amount) => bond_amount, - None => self.calculate_open_long(base_amount), + None => self.calculate_open_long(base_amount)?, }; let mut state: State = self.clone(); state.info.bond_reserves -= bond_amount.into(); state.info.share_reserves += (base_amount / state.vault_share_price() - self.open_long_governance_fee(base_amount) / state.vault_share_price()) .into(); - state.calculate_spot_price() + Ok(state.calculate_spot_price()) } /// Calculate the spot rate after a long has been opened. @@ -74,11 +72,11 @@ impl State { &self, base_amount: FixedPoint, bond_amount: Option, - ) -> FixedPoint { - calculate_rate_given_fixed_price( - self.calculate_spot_price_after_long(base_amount, bond_amount), + ) -> Result { + Ok(calculate_rate_given_fixed_price( + self.calculate_spot_price_after_long(base_amount, bond_amount)?, self.position_duration(), - ) + )) } } diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 63b0f62fe..dcc2f3c8a 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -13,41 +13,45 @@ impl State { target_rate: F, checkpoint_exposure: I, maybe_max_iterations: Option, + maybe_allowable_error: Option, ) -> Result { let budget = budget.into(); - match self.get_targeted_long(target_rate, checkpoint_exposure, maybe_max_iterations) { + match self.get_targeted_long( + target_rate, + checkpoint_exposure, + maybe_max_iterations, + maybe_allowable_error, + ) { Ok(long_amount) => Ok(long_amount.min(budget)), Err(error) => Err(error), } } /// Gets a target long that can be opened given a desired fixed rate. - pub fn get_targeted_long, I: Into>( + fn get_targeted_long, I: Into>( &self, target_rate: F, checkpoint_exposure: I, maybe_max_iterations: Option, + maybe_allowable_error: Option, ) -> Result { let target_rate = target_rate.into(); let checkpoint_exposure = checkpoint_exposure.into(); + let allowable_error = match maybe_allowable_error { + Some(allowable_error) => allowable_error.into(), + None => fixed!(1e14), + }; // Estimate the long that achieves a target rate. let (target_share_reserves, target_bond_reserves) = self.reserves_given_rate_ignoring_exposure(target_rate); let (target_base_delta, target_bond_delta) = self.trade_deltas_from_reserves(target_share_reserves, target_bond_reserves); - println!("spot_target_bond_delta {:#?}", target_bond_delta); - println!("spot_target_base_delta {:#?}", target_base_delta); // Determine what rate was achieved. - let resulting_rate = self.rate_after_long(target_base_delta); // ERROR in here - let resulting_price = self.price_for_given_rate(resulting_rate); - println!("resulting_rate {:#?}", resulting_rate); - println!("resulting_price {:#?}", resulting_price); - - let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); - // TODO: ask alex about an appropriate tolerance for the rate error - let allowable_error = fixed!(1e5); + 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); // Verify solvency and target rate. if self .solvency_after_long(target_base_delta, target_bond_delta, checkpoint_exposure) @@ -56,41 +60,42 @@ impl State { { return Ok(target_base_delta); } else { - // Adjust your first guess - let mut possible_target_base_amount = self.ze() - self.minimum_share_reserves(); - // // TODO: Need to come up with a smarter and safe first guess - // let mut possible_target_base_amount = if resulting_rate > target_rate { - // spot_target_base_amount / fixed!(15e17) // overshot; need a smaller long - // } else { - // spot_target_base_amount * fixed!(15e17) // undershot; need a larger long - // }; + // Choose a first guess + let mut possible_target_base_delta = if resulting_rate > target_rate { + // undershot; use it as our first guess + target_base_delta + } else { + // overshot; use the minimum amount to be safe + self.minimum_transaction_amount() // TODO: Base or bonds or shares? probably base. + }; + // Iteratively find a solution for _ in 0..maybe_max_iterations.unwrap_or(7) { - let possible_target_bond_amount = self - .calculate_open_long(possible_target_base_amount) + let possible_target_bond_delta = self + .calculate_open_long(possible_target_base_delta) .unwrap(); - // TODO: make optional bond amount all the way down (through calc_spot_price_after_long) to avoid - // extra `calculate_open_long` - let resulting_rate = self.rate_after_long(possible_target_base_amount); - let abs_rate_error = self.abs_rate_error(target_rate, resulting_rate); + 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); // If we've done it (solvent & within error), then return the value. if self .solvency_after_long( - possible_target_base_amount, - possible_target_bond_amount, + possible_target_base_delta, + possible_target_bond_delta, checkpoint_exposure, ) .is_some() && abs_rate_error < allowable_error { - return Ok(possible_target_base_amount); + return Ok(possible_target_base_delta); // Otherwise perform another iteration. } else { - let negative_loss_derivative = match self - .negative_targeted_loss_derivative(possible_target_base_amount) - { + let negative_loss_derivative = match self.negative_targeted_loss_derivative( + possible_target_base_delta, + Some(possible_target_bond_delta), + ) { Some(derivative) => derivative, None => { return Err(eyre!( @@ -98,25 +103,29 @@ impl State { )); } }; - let loss = self.targeted_loss(target_rate, possible_target_base_amount); + 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 - possible_target_base_amount = - possible_target_base_amount + loss / negative_loss_derivative; + 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_after_long( - possible_target_base_amount, - self.calculate_open_long(possible_target_base_amount) + possible_target_base_delta, + self.calculate_open_long(possible_target_base_delta) .unwrap(), checkpoint_exposure, ) .is_some() { - return Ok(possible_target_base_amount); + return Ok(possible_target_base_delta); // Otherwise we'll return an error. } else { @@ -125,38 +134,40 @@ impl State { } } - fn price_for_given_rate(&self, rate: FixedPoint) -> FixedPoint { - let annualized_time = - self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - fixed!(1e18) / (rate * annualized_time + fixed!(1e18)) - } - - /// The non-negative error between a target rate and resulting rate + /// The non-negative difference between two values /// TODO: Add docs - fn abs_rate_error(&self, target_rate: FixedPoint, resulting_rate: FixedPoint) -> FixedPoint { - if resulting_rate > target_rate { - resulting_rate - target_rate + fn absolute_difference(&self, x: FixedPoint, y: FixedPoint) -> FixedPoint { + if y > x { + y - x } else { - target_rate - resulting_rate + x - y } } /// The spot fixed rate after a long has been opened /// TODO: Add docs - fn rate_after_long(&self, base_amount: FixedPoint) -> FixedPoint { + fn rate_after_long( + &self, + base_amount: FixedPoint, + bond_amount: Option, + ) -> FixedPoint { let annualized_time = self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let resulting_price = self.get_spot_price_after_long(base_amount); + let resulting_price = self.get_spot_price_after_long(base_amount, bond_amount); (fixed!(1e18) - resulting_price) / (resulting_price * annualized_time) } /// The derivative of the equation for calculating the spot rate after a long /// TODO: Add docs - fn negative_rate_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + fn negative_rate_after_long_derivative( + &self, + base_amount: FixedPoint, + bond_amount: Option, + ) -> 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); - let price_derivative = match self.price_after_long_derivative(base_amount) { + let price = self.get_spot_price_after_long(base_amount, bond_amount); + let price_derivative = match self.price_after_long_derivative(base_amount, bond_amount) { Some(derivative) => derivative, None => return None, }; @@ -176,7 +187,15 @@ impl State { /// The derivative of the price after a long /// TODO: Add docs - fn price_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + fn price_after_long_derivative( + &self, + base_amount: FixedPoint, + bond_amount: Option, + ) -> 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, @@ -189,23 +208,28 @@ impl State { - self.open_long_governance_fee(base_amount) - self.zeta().into()); let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; - let inner_denominator = - self.bond_reserves() - self.calculate_open_long(base_amount).unwrap(); + let inner_denominator = self.bond_reserves() - bond_amount; 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 return Some( inner_derivative * self.time_stretch() - * (inner_numerator / inner_denominator).pow(self.time_stretch() - fixed!(1e18)), + * (inner_denominator / inner_numerator).pow(fixed!(1e18) - self.time_stretch()), ); } /// The loss used for the targeted long optimization process /// TODO: Add docs - fn targeted_loss(&self, target_rate: FixedPoint, base_amount: FixedPoint) -> FixedPoint { - let resulting_rate = self.rate_after_long(base_amount); + 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!"); @@ -215,8 +239,12 @@ impl State { /// Derivative of the targeted long loss /// TODO: Add docs - fn negative_targeted_loss_derivative(&self, base_amount: FixedPoint) -> Option { - match self.negative_rate_after_long_derivative(base_amount) { + 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, } @@ -285,7 +313,7 @@ mod tests { use test_utils::{ agent::Agent, chain::{Chain, TestChain}, - constants::FUZZ_RUNS, + constants::FAST_FUZZ_RUNS, }; use tracing_test::traced_test; @@ -306,6 +334,11 @@ mod tests { // can test `get_targeted_long` when budget is the primary constraint // and when it is not. + let allowable_solvency_error = fixed!(1e5); + let allowable_budget_error = fixed!(1e5); + let allowable_rate_error = fixed!(1e14); + let num_newton_iters = 7; + // Initialize a test chain; don't need mocks because we want state updates. let chain = TestChain::new(2).await?; @@ -320,7 +353,7 @@ mod tests { // Fuzz test let mut rng = thread_rng(); - for _ in 0..*FUZZ_RUNS { + for _ in 0..*FAST_FUZZ_RUNS { // Snapshot the chain. let id = chain.snapshot().await?; @@ -335,8 +368,6 @@ mod tests { alice .initialize(initial_fixed_rate, contribution, None) .await?; - println!("initial state: {:#?}", alice.get_state().await?); - println!("initial_fixed_rate {:#?}", initial_fixed_rate); // Some of the checkpoint passes and variable interest accrues. alice @@ -353,9 +384,13 @@ mod tests { // Bob opens a targeted long. let max_spot_price_before_long = bob.get_state().await?.get_max_spot_price(); let target_rate = initial_fixed_rate / fixed!(2e18); - println!("target_rate {:#?}", target_rate); - let targeted_long = bob.get_targeted_long(target_rate, None).await?; - println!("targeted_long {:#?}", targeted_long); + let targeted_long = bob + .get_targeted_long( + target_rate, + Some(num_newton_iters), + Some(allowable_rate_error), + ) + .await?; bob.open_long(targeted_long, None, None).await?; // Three things should be true after opening the long: @@ -368,27 +403,30 @@ mod tests { let is_under_max_price = max_spot_price_before_long > spot_price_after_long; let is_solvent = { let state = bob.get_state().await?; - let error_tolerance = fixed!(1e5); - state.get_solvency() > error_tolerance + state.get_solvency() > allowable_solvency_error }; - assert!(is_under_max_price && is_solvent, "Invalid targeted long."); - - let is_budget_consumed = { - let error_tolerance = fixed!(1e5); - bob.base() < error_tolerance - }; - let is_rate_achieved = { - let state = bob.get_state().await?; - let new_rate = state.get_spot_rate(); - let error_tolerance = fixed!(1e5); - if new_rate > target_rate { - new_rate - target_rate < error_tolerance - } else { - target_rate - new_rate < error_tolerance - } + 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." + ); + + 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 + } else { + target_rate - new_rate < allowable_rate_error }; if !is_budget_consumed { - assert!(is_rate_achieved, "Invalid targeted long."); + assert!( + is_rate_achieved, + "Invalid targeted long: target_rate was {}, realized rate is {}.", + target_rate, new_rate + ); } // Revert to the snapshot and reset the agent's wallets. diff --git a/crates/test-utils/src/agent.rs b/crates/test-utils/src/agent.rs index 95ef0e2e9..eed41673b 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::{Http, Middleware, Provider, RetryClient}, + providers::{maybe, Http, Middleware, Provider, RetryClient}, types::{Address, BlockId, I256, U256}, }; use eyre::Result; @@ -973,6 +973,7 @@ impl Agent { &self, target_rate: FixedPoint, maybe_max_iterations: Option, + maybe_allowable_error: Option, ) -> Result { let state = self.get_state().await?; let checkpoint_exposure = self @@ -985,6 +986,7 @@ impl Agent { target_rate, checkpoint_exposure, maybe_max_iterations, + maybe_allowable_error, ) .unwrap()) } From 6dbf92b7aa9e2c40a3ca090780f5dd7c8ba87c73 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 1 Apr 2024 15:11:41 -0700 Subject: [PATCH 07/12] bugfix and docstrings --- crates/hyperdrive-math/src/long/targeted.rs | 373 ++++++++++++-------- crates/test-utils/src/agent.rs | 2 +- 2 files changed, 224 insertions(+), 151 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index dcc2f3c8a..4edb67514 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -27,7 +27,7 @@ impl State { } } - /// Gets a target long that can be opened given a desired fixed rate. + /// Gets a target long that can be opened to achieve a desired fixed rate. fn get_targeted_long, I: Into>( &self, target_rate: F, @@ -49,25 +49,25 @@ impl State { self.trade_deltas_from_reserves(target_share_reserves, target_bond_reserves); // Determine what rate was achieved. - let resulting_rate = self.rate_after_long(target_base_delta, Some(target_bond_delta)); // ERROR in here + let resulting_rate = self.rate_after_long(target_base_delta, Some(target_bond_delta)); + + // 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; - let abs_rate_error = self.absolute_difference(target_rate, resulting_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 { - // Choose a first guess - let mut possible_target_base_delta = if resulting_rate > target_rate { - // undershot; use it as our first guess - target_base_delta - } else { - // overshot; use the minimum amount to be safe - self.minimum_transaction_amount() // TODO: Base or bonds or shares? probably base. - }; + // We can use the initial guess as a starting point since we know it is less than the target. + let mut possible_target_base_delta = target_base_delta; // Iteratively find a solution for _ in 0..maybe_max_iterations.unwrap_or(7) { @@ -76,7 +76,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 +94,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 +114,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 + // Final solvency check. + if !self .solvency_after_long( possible_target_base_delta, self.calculate_open_long(possible_target_base_delta) @@ -125,27 +133,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.")); + // Final accuracy check. + 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 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 +180,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 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 +290,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 + /// Calculate the base & bond deltas from the current state given desired new reserve levels. + /// + /// 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(\Delta x) + /// $$ + /// + /// where $c$ is the vault share price and + /// $c(\Delta 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 or exposure + /// 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; + // Then get the 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 +377,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,16 +394,16 @@ 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?; - // Grab accounts for Alice, Bob, and Claire. + // Grab accounts for Alice and Bob. let (alice, bob) = (chain.accounts()[0].clone(), chain.accounts()[1].clone()); - // Initialize Alice, Bob, and Claire as Agents. + // Initialize Alice and Bob as Agents. let mut alice = Agent::new(chain.client(alice).await?, chain.addresses().clone(), None).await?; let mut bob = Agent::new(chain.client(bob).await?, chain.addresses(), None).await?; @@ -353,7 +411,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 +456,49 @@ 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, + // or by some coincidence the budget was the perfect amount + // and we hit the rate exactly. } 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 eed41673b..057dfb891 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; From 5eda840fccaa5f132e349af0b9353b793377cb82 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 1 Apr 2024 18:06:05 -0700 Subject: [PATCH 08/12] fix type signatures to support multiple input types --- crates/hyperdrive-math/src/long/targeted.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 4edb67514..c160ced04 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -7,13 +7,18 @@ use crate::{State, YieldSpace}; impl State { /// Gets a target long that can be opened given a budget to achieve a desired fixed rate. - pub fn get_targeted_long_with_budget, I: Into>( + pub fn get_targeted_long_with_budget< + F1: Into, + F2: Into, + F3: Into, + I: Into, + >( &self, - budget: F, - target_rate: F, + budget: F1, + target_rate: F2, checkpoint_exposure: I, maybe_max_iterations: Option, - maybe_allowable_error: Option, + maybe_allowable_error: Option, ) -> Result { let budget = budget.into(); match self.get_targeted_long( @@ -28,12 +33,12 @@ impl State { } /// Gets a target long that can be opened to achieve a desired fixed rate. - fn get_targeted_long, I: Into>( + fn get_targeted_long, F2: Into, I: Into>( &self, - target_rate: F, + target_rate: F1, checkpoint_exposure: I, maybe_max_iterations: Option, - maybe_allowable_error: Option, + maybe_allowable_error: Option, ) -> Result { let target_rate = target_rate.into(); let checkpoint_exposure = checkpoint_exposure.into(); From d303a4ec9bddff08380351ad2d19f6c7cd0558ae Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 1 Apr 2024 20:19:09 -0700 Subject: [PATCH 09/12] addressing some feedback --- crates/hyperdrive-math/src/long/targeted.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index c160ced04..1027579c3 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -108,7 +108,7 @@ impl State { // 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( + let negative_loss_derivative = match self.rate_after_long_derivative_negation( possible_target_base_delta, possible_target_bond_delta, ) { @@ -197,7 +197,7 @@ impl State { /// $$ /// /// We return $-r'(x)$ because negative numbers cannot be represented by FixedPoint. - fn negative_rate_after_long_derivative( + fn rate_after_long_derivative_negation( &self, base_amount: FixedPoint, bond_amount: FixedPoint, @@ -319,7 +319,7 @@ impl State { (base_delta, bond_delta) } - /// Calculates the long that should be opened to hit a target interest rate. + /// Calculates the pool reserve levels to achieve a target interest rate. /// This calculation does not take Hyperdrive's solvency constraints or exposure /// into account and shouldn't be used directly. /// From 2fa48d10c65749385c7f61bb98fcd27b10f7bfd5 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 2 Apr 2024 11:54:00 -0700 Subject: [PATCH 10/12] fixes rebase issues --- crates/hyperdrive-math/src/long/targeted.rs | 27 +++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 1027579c3..af43c8b4a 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -7,7 +7,7 @@ use crate::{State, YieldSpace}; impl State { /// Gets a target long that can be opened given a budget to achieve a desired fixed rate. - pub fn get_targeted_long_with_budget< + pub fn calculate_targeted_long_with_budget< F1: Into, F2: Into, F3: Into, @@ -21,7 +21,7 @@ impl State { maybe_allowable_error: Option, ) -> Result { let budget = budget.into(); - match self.get_targeted_long( + match self.calculate_targeted_long( target_rate, checkpoint_exposure, maybe_max_iterations, @@ -33,7 +33,7 @@ impl State { } /// Gets a target long that can be opened to achieve a desired fixed rate. - fn get_targeted_long, F2: Into, I: Into>( + fn calculate_targeted_long, F2: Into, I: Into>( &self, target_rate: F1, checkpoint_exposure: I, @@ -181,7 +181,7 @@ impl State { ) -> FixedPoint { let annualized_time = self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let resulting_price = self.get_spot_price_after_long(base_amount, bond_amount); + let resulting_price = self.calculate_spot_price_after_long(base_amount, bond_amount); (fixed!(1e18) - resulting_price) / (resulting_price * annualized_time) } @@ -204,7 +204,7 @@ impl State { ) -> 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, Some(bond_amount)); + let price = self.calculate_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, @@ -258,8 +258,9 @@ impl State { bond_amount: FixedPoint, ) -> Option { // g'(x) - let gov_fee_derivative = - self.governance_lp_fee() * self.curve_fee() * (fixed!(1e18) - self.get_spot_price()); + let gov_fee_derivative = self.governance_lp_fee() + * self.curve_fee() + * (fixed!(1e18) - self.calculate_spot_price()); // a(x) = u (z_e + x/c - g(x) - zeta) let inner_numerator = self.mu() @@ -390,11 +391,11 @@ mod tests { #[traced_test] #[tokio::test] - async fn test_get_targeted_long_with_budget() -> Result<()> { + async fn test_calculate_targeted_long_with_budget() -> Result<()> { // Spawn a test chain and create two agents -- Alice and Bob. // Alice is funded with a large amount of capital so that she can initialize // the pool. Bob is funded with a random amount of capital so that we - // can test `get_targeted_long` when budget is the primary constraint + // can test `calculate_targeted_long` when budget is the primary constraint // and when it is not. let allowable_solvency_error = fixed!(1e5); @@ -445,10 +446,10 @@ mod tests { .await?; // Bob opens a targeted long. - let max_spot_price_before_long = bob.get_state().await?.get_max_spot_price(); + let max_spot_price_before_long = bob.get_state().await?.calculate_max_spot_price(); let target_rate = initial_fixed_rate / fixed!(2e18); let targeted_long = bob - .get_targeted_long( + .calculate_targeted_long( target_rate, Some(num_newton_iters), Some(allowable_rate_error), @@ -464,7 +465,7 @@ mod tests { // 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 spot_price_after_long = bob.get_state().await?.calculate_spot_price(); assert!( max_spot_price_before_long > spot_price_after_long, "Resulting price is greater than the max." @@ -478,7 +479,7 @@ mod tests { 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 new_rate = bob.get_state().await?.calculate_spot_rate(); if !(bob.base() <= allowable_budget_error) { // Actual price might result in long overshooting the target. let abs_error = if target_rate > new_rate { From 1189bb064475b7e1f00021c462687715181a4472 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 4 Apr 2024 12:33:04 -0500 Subject: [PATCH 11/12] lint fixes from rebase --- crates/hyperdrive-math/src/long/max.rs | 2 +- crates/hyperdrive-math/src/long/open.rs | 7 +- crates/hyperdrive-math/src/long/targeted.rs | 70 +++++++------------ .../tests/integration_tests.rs | 2 +- crates/test-utils/src/agent.rs | 4 +- 5 files changed, 34 insertions(+), 51 deletions(-) diff --git a/crates/hyperdrive-math/src/long/max.rs b/crates/hyperdrive-math/src/long/max.rs index 2dc78932b..204a3ba12 100644 --- a/crates/hyperdrive-math/src/long/max.rs +++ b/crates/hyperdrive-math/src/long/max.rs @@ -624,7 +624,7 @@ mod tests { let spot_price_after_long = bob .get_state() .await? - .calculate_spot_price_after_long(max_long, None); + .calculate_spot_price_after_long(max_long, None)?; bob.open_long(max_long, None, None).await?; // One of three things should be true after opening the long: diff --git a/crates/hyperdrive-math/src/long/open.rs b/crates/hyperdrive-math/src/long/open.rs index cc7cf94cc..95b6dc0c4 100644 --- a/crates/hyperdrive-math/src/long/open.rs +++ b/crates/hyperdrive-math/src/long/open.rs @@ -36,7 +36,7 @@ impl State { // Throw an error if opening the long would result in negative interest. let ending_spot_price = - self.calculate_spot_price_after_long(base_amount, long_amount.into()); + self.calculate_spot_price_after_long(base_amount, long_amount.into())?; let max_spot_price = self.calculate_max_spot_price(); if ending_spot_price > max_spot_price { return Err(eyre!( @@ -82,7 +82,6 @@ impl State { #[cfg(test)] mod tests { - use eyre::Result; use fixed_point_macros::fixed; use rand::{thread_rng, Rng}; use test_utils::{ @@ -126,7 +125,7 @@ mod tests { let expected_spot_price = bob .get_state() .await? - .calculate_spot_price_after_long(base_paid, None); + .calculate_spot_price_after_long(base_paid, None)?; // Open the long. bob.open_long(base_paid, None, None).await?; @@ -190,7 +189,7 @@ mod tests { let expected_spot_rate = bob .get_state() .await? - .calculate_spot_rate_after_long(base_paid, None); + .calculate_spot_rate_after_long(base_paid, None)?; // Open the long. bob.open_long(base_paid, None, None).await?; diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index af43c8b4a..2e7e23a46 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -1,4 +1,4 @@ -use ethers::types::{I256, U256}; +use ethers::types::I256; use eyre::{eyre, Result}; use fixed_point::FixedPoint; use fixed_point_macros::fixed; @@ -54,7 +54,7 @@ impl State { self.trade_deltas_from_reserves(target_share_reserves, target_bond_reserves); // Determine what rate was achieved. - let resulting_rate = self.rate_after_long(target_base_delta, Some(target_bond_delta)); + let resulting_rate = self.rate_after_long(target_base_delta, Some(target_bond_delta))?; // The estimated long should always underestimate because the realized price // should always be greater than the spot price. @@ -69,7 +69,7 @@ impl State { .is_some() && rate_error < allowable_error { - return Ok(target_base_delta); + Ok(target_base_delta) } else { // We can use the initial guess as a starting point since we know it is less than the target. let mut possible_target_base_delta = target_base_delta; @@ -79,8 +79,10 @@ impl State { 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)); + let resulting_rate = self.rate_after_long( + possible_target_base_delta, + Some(possible_target_bond_delta), + )?; // We assume that the loss is positive only because Newton's // method and the one-shot approximation will always underestimate. @@ -108,17 +110,10 @@ impl State { // 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.rate_after_long_derivative_negation( + let negative_loss_derivative = self.rate_after_long_derivative_negation( possible_target_base_delta, possible_target_bond_delta, - ) { - Some(derivative) => derivative, - None => { - return Err(eyre!( - "get_targeted_long: Invalid value when calculating targeted loss derivative.", - )); - } - }; + )?; // Adding the negative loss derivative instead of subtracting the loss derivative // ∆x_{n+1} = ∆x_{n} - l / l' @@ -129,14 +124,14 @@ impl State { } // Final solvency check. - if !self + if self .solvency_after_long( possible_target_base_delta, self.calculate_open_long(possible_target_base_delta) .unwrap(), checkpoint_exposure, ) - .is_some() + .is_none() { return Err(eyre!("Guess in `get_targeted_long` is insolvent.")); } @@ -146,7 +141,7 @@ impl State { .calculate_open_long(possible_target_base_delta) .unwrap(); let resulting_rate = - self.rate_after_long(possible_target_base_delta, Some(possible_target_bond_delta)); + 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.",)); } @@ -178,11 +173,10 @@ impl State { &self, base_amount: FixedPoint, bond_amount: Option, - ) -> FixedPoint { - let annualized_time = - self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let resulting_price = self.calculate_spot_price_after_long(base_amount, bond_amount); - (fixed!(1e18) - resulting_price) / (resulting_price * annualized_time) + ) -> Result { + let resulting_price = self.calculate_spot_price_after_long(base_amount, bond_amount)?; + Ok((fixed!(1e18) - resulting_price) + / (resulting_price * self.annualized_position_duration())) } /// The derivative of the equation for calculating the rate after a long. @@ -201,19 +195,14 @@ impl State { &self, base_amount: FixedPoint, bond_amount: FixedPoint, - ) -> Option { - let annualized_time = - self.position_duration() / FixedPoint::from(U256::from(60 * 60 * 24 * 365)); - let price = self.calculate_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, - }; + ) -> Result { + let price = self.calculate_spot_price_after_long(base_amount, Some(bond_amount))?; + let price_derivative = self.price_after_long_derivative(base_amount, bond_amount)?; // 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. - Some(price_derivative / (annualized_time * price.pow(fixed!(2e18)))) + Ok(price_derivative / (self.annualized_position_duration() * price.pow(fixed!(2e18)))) } /// The derivative of the price after a long. @@ -256,7 +245,7 @@ impl State { &self, base_amount: FixedPoint, bond_amount: FixedPoint, - ) -> Option { + ) -> Result { // g'(x) let gov_fee_derivative = self.governance_lp_fee() * self.curve_fee() @@ -277,7 +266,7 @@ impl State { // b'(x) = Y'(x) let long_amount_derivative = match self.long_amount_derivative(base_amount) { Some(derivative) => derivative, - None => return None, + None => return Err(eyre!("long_amount_derivative failure.")), }; // v(x) = a(x) / b(x) @@ -289,11 +278,9 @@ impl State { // 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() - * (inner_denominator / inner_numerator).pow(fixed!(1e18) - self.time_stretch()), - ); + Ok(inner_derivative + * self.time_stretch() + * (inner_denominator / inner_numerator).pow(fixed!(1e18) - self.time_stretch())) } /// Calculate the base & bond deltas from the current state given desired new reserve levels. @@ -355,14 +342,12 @@ impl State { target_rate: F, ) -> (FixedPoint, FixedPoint) { 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)) + let scaled_rate = (target_rate.mul_up(self.annualized_position_duration()) + fixed!(1e18)) .pow(fixed!(1e18) / self.time_stretch()); let target_base_reserves = (self.k_down() / (c_over_mu + scaled_rate.pow(fixed!(1e18) - self.time_stretch()))) @@ -378,7 +363,6 @@ impl State { #[cfg(test)] mod tests { - use eyre::Result; use rand::{thread_rng, Rng}; use test_utils::{ agent::Agent, @@ -474,7 +458,7 @@ mod tests { // Check solvency let is_solvent = { let state = bob.get_state().await?; - state.get_solvency() > allowable_solvency_error + state.calculate_solvency() > allowable_solvency_error }; assert!(is_solvent, "Resulting pool state is not solvent."); diff --git a/crates/hyperdrive-math/tests/integration_tests.rs b/crates/hyperdrive-math/tests/integration_tests.rs index c8a2803b2..22c261139 100644 --- a/crates/hyperdrive-math/tests/integration_tests.rs +++ b/crates/hyperdrive-math/tests/integration_tests.rs @@ -216,7 +216,7 @@ pub async fn test_integration_calculate_max_long() -> Result<()> { let spot_price_after_long = bob .get_state() .await? - .calculate_spot_price_after_long(max_long, None); + .calculate_spot_price_after_long(max_long, None)?; bob.open_long(max_long, None, None).await?; let is_max_price = max_spot_price - spot_price_after_long < fixed!(1e15); let is_solvency_consumed = { diff --git a/crates/test-utils/src/agent.rs b/crates/test-utils/src/agent.rs index 057dfb891..fb975e168 100644 --- a/crates/test-utils/src/agent.rs +++ b/crates/test-utils/src/agent.rs @@ -969,7 +969,7 @@ impl Agent { } /// Gets the long that moves the fixed rate to a target value. - pub async fn get_targeted_long( + pub async fn calculate_targeted_long( &self, target_rate: FixedPoint, maybe_max_iterations: Option, @@ -981,7 +981,7 @@ impl Agent { .get_checkpoint_exposure(state.to_checkpoint(self.now().await?)) .await?; Ok(state - .get_targeted_long_with_budget( + .calculate_targeted_long_with_budget( self.wallet.base, target_rate, checkpoint_exposure, From e9c4477ce9a273bea773a81e40a9199a68c9af1c Mon Sep 17 00:00:00 2001 From: Dylan Date: Fri, 5 Apr 2024 09:53:15 -0500 Subject: [PATCH 12/12] addressing feedback --- crates/hyperdrive-math/src/long/max.rs | 9 +- crates/hyperdrive-math/src/long/targeted.rs | 160 +++++++++++++------- 2 files changed, 108 insertions(+), 61 deletions(-) diff --git a/crates/hyperdrive-math/src/long/max.rs b/crates/hyperdrive-math/src/long/max.rs index 204a3ba12..b9ade97cf 100644 --- a/crates/hyperdrive-math/src/long/max.rs +++ b/crates/hyperdrive-math/src/long/max.rs @@ -340,7 +340,7 @@ impl State { /// It's possible that the pool is insolvent after opening a long. In this /// case, we return `None` since the fixed point library can't represent /// negative numbers. - pub fn solvency_after_long( + pub(super) fn solvency_after_long( &self, base_amount: FixedPoint, bond_amount: FixedPoint, @@ -380,7 +380,10 @@ impl State { /// This derivative is negative since solvency decreases as more longs are /// opened. We use the negation of the derivative to stay in the positive /// domain, which allows us to use the fixed point library. - pub fn solvency_after_long_derivative(&self, base_amount: FixedPoint) -> Option { + pub(super) fn solvency_after_long_derivative( + &self, + base_amount: FixedPoint, + ) -> Option { let maybe_derivative = self.long_amount_derivative(base_amount); maybe_derivative.map(|derivative| { (derivative @@ -419,7 +422,7 @@ impl State { /// $$ /// c'(x) = \phi_{c} \cdot \left( \tfrac{1}{p} - 1 \right) /// $$ - pub fn long_amount_derivative(&self, base_amount: FixedPoint) -> Option { + pub(super) fn long_amount_derivative(&self, base_amount: FixedPoint) -> Option { let share_amount = base_amount / self.vault_share_price(); let inner = self.initial_vault_share_price() * (self.effective_share_reserves() + share_amount); diff --git a/crates/hyperdrive-math/src/long/targeted.rs b/crates/hyperdrive-math/src/long/targeted.rs index 2e7e23a46..463ac750d 100644 --- a/crates/hyperdrive-math/src/long/targeted.rs +++ b/crates/hyperdrive-math/src/long/targeted.rs @@ -7,6 +7,10 @@ use crate::{State, YieldSpace}; impl State { /// Gets a target long that can be opened given a budget to achieve a desired fixed rate. + /// + /// If the long amount to reach the target is greater than the budget, the budget is returned. + /// If the long amount to reach the target is invalid (i.e. it would produce an insolvent pool), then + /// an error is thrown, and the user is advised to use [calculate_max_long](long::max::calculate_max_long). pub fn calculate_targeted_long_with_budget< F1: Into, F2: Into, @@ -63,14 +67,16 @@ impl State { } let rate_error = resulting_rate - target_rate; - // Verify solvency and target rate. + // If solvent & within the allowable error, stop here. if self .solvency_after_long(target_base_delta, target_bond_delta, checkpoint_exposure) .is_some() && rate_error < allowable_error { Ok(target_base_delta) - } else { + } + // Else, iterate to find a solution. + else { // We can use the initial guess as a starting point since we know it is less than the target. let mut possible_target_base_delta = target_base_delta; @@ -104,9 +110,9 @@ impl State { && loss < allowable_error { return Ok(possible_target_base_delta); - + } // Otherwise perform another iteration. - } else { + else { // 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. @@ -146,9 +152,9 @@ impl State { return Err(eyre!("get_targeted_long: We overshot the zero-crossing.",)); } let loss = resulting_rate - target_rate; - if !(loss < allowable_error) { + if loss >= allowable_error { return Err(eyre!( - "get_targeted_long: Unable to find an acceptible loss. Final loss = {}.", + "get_targeted_long: Unable to find an acceptable loss. Final loss = {}.", loss )); } @@ -181,13 +187,13 @@ impl State { /// The derivative of the equation for calculating the rate after a long. /// - /// For some $r = (1 - p(x)) / (p(x) * t)$, where $p(x)$ + /// For some $r = (1 - p(x)) / (p(x) \cdot 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} + /// r'(x) = \frac{(-p'(x) \cdot p(x) t - (1 - p(x)) (p'(x) \cdot t))}{(p(x) \cdot t)^2} // + /// r'(x) = \frac{-p'(x)}{t \cdot p(x)^2} /// $$ /// /// We return $-r'(x)$ because negative numbers cannot be represented by FixedPoint. @@ -199,46 +205,56 @@ impl State { let price = self.calculate_spot_price_after_long(base_amount, Some(bond_amount))?; let price_derivative = self.price_after_long_derivative(base_amount, bond_amount)?; // The actual equation we want to represent is: - // r' = -p' / (t p^2) + // r' = -p' / (t \cdot p^2) // We can do a trick to return a positive-only version and // indicate that it should be negative in the fn name. - Ok(price_derivative / (self.annualized_position_duration() * price.pow(fixed!(2e18)))) + // We use price * price instead of price.pow(fixed!(2e18)) to avoid error introduced by pow. + Ok(price_derivative / (self.annualized_position_duration() * price * price)) } /// 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: + /// is equal to /// /// $$ - /// p(x) = \frac{\mu (z_e + \frac{x}{c} - g(x) - \zeta)}{y_0 - Y(x)}^{T} + /// p(\Delta z) = (\frac{\mu \cdot (z_{0} + \Delta z - (\zeta_{0} + \Delta \zeta))}{y - \Delta y})^{t_{s}} /// $$ + /// + /// where $t_{s}$ is the time stretch constant and $z_{e,0}$ is the initial + /// effective share reserves, and $\zeta$ is the zeta adjustment. + /// The zeta adjustment is constant when opening a long, i.e. + /// $\Delta \zeta = 0$, so we drop the subscript. Equivalently, for some + /// amount of `delta_base`$= x$ provided to open a long, we can write: + /// + /// $$ + /// p(x) = (\frac{\mu \cdot (z_{e,0} + \frac{x}{c} - g(x) - \zeta)}{y_0 - y(x)})^{t_{s}} + /// $$ + /// /// 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. + /// $y(x)$ is the [long_amount](long::open::calculate_open_long), + /// /// /// 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) \\ + /// a(x) = \mu (z_{0} + \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: + /// and thus $p(x) = v(x)^t_{s}$. 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} + /// b'(x) = -y'(x) \\ + /// v'(x) = \frac{b(x) \cdot a'(x) - a(x) \cdot b'(x)}{b(x)^2} /// $$ /// /// And finally, the price after long derivative is: /// /// $$ - /// p'(x) = v'(x) T v(x)^(T-1) + /// p'(x) = v'(x) \cdot t_{s} \cdot v(x)^(t_{s} - 1) /// $$ /// fn price_after_long_derivative( @@ -251,32 +267,33 @@ impl State { * self.curve_fee() * (fixed!(1e18) - self.calculate_spot_price()); - // a(x) = u (z_e + x/c - g(x) - zeta) + // a(x) = mu * (z_{e,0} + x/c - g(x)) let inner_numerator = self.mu() * (self.ze() + base_amount / self.vault_share_price() - - self.open_long_governance_fee(base_amount) - - self.zeta().into()); + - self.open_long_governance_fee(base_amount)); - // a'(x) = u / c - g'(x) + // a'(x) = mu / c - g'(x) let inner_numerator_derivative = self.mu() / self.vault_share_price() - gov_fee_derivative; - // b(x) = y_0 - Y(x) + // b(x) = y_0 - y(x) let inner_denominator = self.bond_reserves() - bond_amount; - // b'(x) = Y'(x) + // b'(x) = -y'(x) let long_amount_derivative = match self.long_amount_derivative(base_amount) { Some(derivative) => derivative, None => return Err(eyre!("long_amount_derivative failure.")), }; // v(x) = a(x) / b(x) - // v'(x) = ( b(x) * a'(x) + a(x) * b'(x) ) / b(x)^2 + // v'(x) = ( b(x) * a'(x) - a(x) * b'(x) ) / b(x)^2 + // = ( b(x) * a'(x) + a(x) * -b'(x) ) / b(x)^2 + // Note that we are adding the negative b'(x) to avoid negative fixedpoint numbers let inner_derivative = (inner_denominator * inner_numerator_derivative + inner_numerator * long_amount_derivative) - / inner_denominator.pow(fixed!(2e18)); + / (inner_denominator * inner_denominator); - // p'(x) = v'(x) T v(x)^(T-1) - // p'(x) = v'(x) T v(x)^(-1)^(1-T) + // p'(x) = v'(x) * t_s * v(x)^(t_s - 1) + // p'(x) = v'(x) * t_s * v(x)^(-1)^(1 - t_s) // v(x) is flipped to (denominator / numerator) to avoid a negative exponent Ok(inner_derivative * self.time_stretch() @@ -289,7 +306,7 @@ impl State { /// the trade deltas to achieve that state would be: /// /// $$ - /// \Delta x = c * (z_t - z_e) \\ + /// \Delta x = c \cdot (z_t - z_{e,0}) \\ /// \Delta y = y - y_t - c(\Delta x) /// $$ /// @@ -311,10 +328,10 @@ impl State { /// This calculation does not take Hyperdrive's solvency constraints or exposure /// into account and shouldn't be used directly. /// - /// The price for a given fixed-rate is given by $p = 1 / (r t + 1)$, where + /// The price for a given fixed-rate is given by $p = 1 / (r \cdot 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 + /// price for a given pool reserves is given by $p = \frac{\mu z}{y}^t_{s}$, + /// where $\mu$ is the initial share price and $t_{s}$ is the time stretch /// constant. By setting these equal we can solve for the pool reserve levels /// as a function of a target rate. /// @@ -323,9 +340,9 @@ impl State { /// $$ /// 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}} + /// (r_t \cdot t + 1)^{\frac{1}{t_{s}}} + /// \right)^{1 - t_{s}}} + /// \right)^{\tfrac{1}{1 - t_{s}}} /// $$ /// /// and the pool bond reserves, $y_t$, must be: @@ -333,9 +350,9 @@ impl State { /// $$ /// 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}} + /// \left( r_t \cdot t + 1 \right)^{\frac{1}{t_{s}}} + /// \right)^{1 - t_{s}}} + /// \right)^{1 - t_{s}} \left( r_t t + 1 \right)^{\frac{1}{t_{s}}} /// $$ fn reserves_given_rate_ignoring_exposure>( &self, @@ -349,13 +366,13 @@ impl State { .div_up(self.initial_vault_share_price()); let scaled_rate = (target_rate.mul_up(self.annualized_position_duration()) + fixed!(1e18)) .pow(fixed!(1e18) / self.time_stretch()); - let target_base_reserves = (self.k_down() + let inner = (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 = target_base_reserves / self.initial_vault_share_price(); + let target_share_reserves = inner / self.initial_vault_share_price(); // Then get the target bond reserves. - let target_bond_reserves = target_base_reserves * scaled_rate; + let target_bond_reserves = inner * scaled_rate; (target_share_reserves, target_bond_reserves) } @@ -363,6 +380,9 @@ impl State { #[cfg(test)] mod tests { + use std::panic; + + use ethers::types::U256; use rand::{thread_rng, Rng}; use test_utils::{ agent::Agent, @@ -385,9 +405,9 @@ mod tests { let allowable_solvency_error = fixed!(1e5); let allowable_budget_error = fixed!(1e5); let allowable_rate_error = fixed!(1e10); - let num_newton_iters = 3; + let num_newton_iters = 5; - // Initialize a test chain; don't need mocks because we want state updates. + // Initialize a test chain. We don't need mocks because we want state updates. let chain = TestChain::new(2).await?; // Grab accounts for Alice and Bob. @@ -406,10 +426,11 @@ mod tests { let id = chain.snapshot().await?; // Fund Alice and Bob. + // Large budget for initializing the pool. let contribution = fixed!(1_000_000e18); - alice.fund(contribution).await?; // large budget for initializing the pool + alice.fund(contribution).await?; + // Small lower bound on the budget for resource-constrained targeted longs. let budget = rng.gen_range(fixed!(10e18)..=fixed!(500_000_000e18)); - bob.fund(budget).await?; // small budget for resource-constrained targeted longs // Alice initializes the pool. let initial_fixed_rate = rng.gen_range(fixed!(0.01e18)..=fixed!(0.1e18)); @@ -417,6 +438,31 @@ mod tests { .initialize(initial_fixed_rate, contribution, None) .await?; + // Half the time we will open a long & let it mature. + if rng.gen_range(0..=1) == 0 { + // Open a long. + let max_long = + bob.get_state() + .await? + .calculate_max_long(U256::MAX, I256::from(0), None); + let long_amount = + (max_long / fixed!(100e18)).max(config.minimum_transaction_amount.into()); + bob.fund(long_amount + budget).await?; + bob.open_long(long_amount, None, None).await?; + // Advance time to just after maturity. + let variable_rate = rng.gen_range(fixed!(0)..=fixed!(0.5e18)); + let time_amount = FixedPoint::from(config.position_duration) * fixed!(105e17); // 1.05 * position_duraiton + alice.advance_time(variable_rate, time_amount).await?; + // Checkpoint to auto-close the position. + alice + .checkpoint(alice.latest_checkpoint().await?, None) + .await?; + } + // Else we will just fund a random budget amount and do the targeted long. + else { + bob.fund(budget).await?; + } + // Some of the checkpoint passes and variable interest accrues. alice .checkpoint(alice.latest_checkpoint().await?, None) @@ -456,14 +502,12 @@ mod tests { ); // Check solvency - let is_solvent = { - let state = bob.get_state().await?; - state.calculate_solvency() > allowable_solvency_error - }; + let is_solvent = + { bob.get_state().await?.calculate_solvency() > allowable_solvency_error }; 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?.calculate_spot_rate(); + // If the budget was NOT consumed, then we assume the target was hit. if !(bob.base() <= allowable_budget_error) { // Actual price might result in long overshooting the target. let abs_error = if target_rate > new_rate { @@ -479,11 +523,11 @@ mod tests { abs_error, allowable_rate_error ); - + } // Else, we should have undershot, // or by some coincidence the budget was the perfect amount // and we hit the rate exactly. - } else { + else { assert!( new_rate <= target_rate, "The new_rate={} should be <= target_rate={} when budget constrained.",