-
Notifications
You must be signed in to change notification settings - Fork 305
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
pcli: wip linear liquidity strategies
- Loading branch information
1 parent
bfd7227
commit 09f52a1
Showing
3 changed files
with
624 additions
and
241 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,293 +1,108 @@ | ||
use std::io::Write; | ||
use std::path::PathBuf; | ||
use crate::App; | ||
|
||
use anyhow::{anyhow, bail, Context, Result}; | ||
use dialoguer::Confirm; | ||
use rand_core::OsRng; | ||
pub mod linear; | ||
pub mod xyk; | ||
|
||
use penumbra_asset::Value; | ||
use penumbra_dex::{lp::position::Position, DirectedUnitPair}; | ||
use penumbra_keys::keys::AddressIndex; | ||
use penumbra_num::{fixpoint::U128x128, Amount}; | ||
use penumbra_proto::{ | ||
core::component::dex::v1::{ | ||
query_service_client::QueryServiceClient as DexQueryServiceClient, SpreadRequest, | ||
}, | ||
view::v1::GasPricesRequest, | ||
use linear::Linear; | ||
use penumbra_dex::DirectedUnitPair; | ||
use penumbra_proto::core::component::dex::v1::{ | ||
query_service_client::QueryServiceClient as DexQueryServiceClient, SpreadRequest, | ||
}; | ||
use penumbra_view::{Planner, ViewClient}; | ||
|
||
use crate::dex_utils; | ||
use crate::dex_utils::replicate::debug; | ||
use crate::{warning, App}; | ||
use xyk::ConstantProduct; | ||
|
||
/// Queries the chain for a transaction by hash. | ||
#[derive(Debug, clap::Subcommand)] | ||
pub enum ReplicateCmd { | ||
#[clap(visible_alias = "xyk")] | ||
/// Create a set of positions that attempt to replicate an xy=k (UniV2) AMM. | ||
// Hidden pending further testing & review | ||
#[clap(visible_alias = "xyk", hide = true)] | ||
ConstantProduct(ConstantProduct), | ||
/// Create a set of positions that allocate liquidity linearly across a price range. | ||
Linear(Linear), | ||
} | ||
|
||
impl ReplicateCmd { | ||
pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> { | ||
match self { | ||
ReplicateCmd::ConstantProduct(xyk_cmd) => xyk_cmd.exec(app).await?, | ||
ReplicateCmd::Linear(linear_cmd) => linear_cmd.exec(app).await?, | ||
}; | ||
Ok(()) | ||
} | ||
|
||
pub fn offline(&self) -> bool { | ||
match self { | ||
ReplicateCmd::ConstantProduct(_) => false, | ||
} | ||
false | ||
} | ||
} | ||
|
||
#[derive(Debug, Clone, clap::Args)] | ||
pub struct ConstantProduct { | ||
pub pair: DirectedUnitPair, | ||
pub input: Value, | ||
|
||
#[clap(short, long)] | ||
pub current_price: Option<f64>, | ||
|
||
#[clap(short, long, default_value_t = 0u32)] | ||
pub fee_bps: u32, | ||
/// `--yes` means all prompt interaction are skipped and agreed. | ||
#[clap(short, long)] | ||
pub yes: bool, | ||
|
||
#[clap(short, long, hide(true))] | ||
pub debug_file: Option<PathBuf>, | ||
#[clap(long, default_value = "0", hide(true))] | ||
pub source: u32, | ||
} | ||
|
||
impl ConstantProduct { | ||
pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> { | ||
self.validate()?; | ||
let pair = self.pair.clone(); | ||
let current_price = match self.current_price { | ||
Some(user_supplied_price) => user_supplied_price, | ||
None => self.get_spread(app).await?, | ||
}; | ||
|
||
let positions = dex_utils::replicate::xyk::replicate( | ||
&pair, | ||
&self.input, | ||
current_price.try_into()?, | ||
self.fee_bps, | ||
)?; | ||
|
||
let (amount_start, amount_end) = | ||
positions | ||
.iter() | ||
.fold((Amount::zero(), Amount::zero()), |acc, pos| { | ||
( | ||
acc.0 | ||
+ pos | ||
.reserves_for(pair.start.id()) | ||
.expect("start is part of position"), | ||
acc.1 | ||
+ pos | ||
.reserves_for(pair.end.id()) | ||
.expect("end is part of position"), | ||
) | ||
}); | ||
let amount_start = pair.start.format_value(amount_start); | ||
let amount_end = pair.end.format_value(amount_end); | ||
|
||
warning::rmm(); | ||
pub async fn process_price_or_fetch_spread( | ||
app: &mut App, | ||
user_price: Option<f64>, | ||
directed_pair: DirectedUnitPair, | ||
) -> anyhow::Result<f64> { | ||
let canonical_pair = directed_pair.into_directed_trading_pair().to_canonical(); | ||
|
||
if !self.yes | ||
&& !Confirm::new() | ||
.with_prompt("In the solemn voice of Mandos, he who sets the fates of all, you hear a question,\nechoing like a whisper through the Halls of Waiting:\n\"Do you, in your heart of hearts, truly wish to proceed?\"") | ||
.interact()? | ||
{ | ||
return Ok(()); | ||
} | ||
println!("\nso it shall be...\n\n"); | ||
println!( | ||
"#################################################################################" | ||
); | ||
println!( | ||
"########################### LIQUIDITY SUMMARY ###################################" | ||
); | ||
println!( | ||
"#################################################################################" | ||
); | ||
println!("\nYou want to provide liquidity on the pair {}", pair); | ||
println!("You will need:",); | ||
println!(" -> {amount_start}{}", pair.start); | ||
println!(" -> {amount_end}{}", pair.end); | ||
// TODO(erwan): would be nice to print current balance? | ||
|
||
println!("You will create the following pools:"); | ||
let asset_cache = app.view().assets().await?; | ||
println!( | ||
"{}", | ||
crate::command::utils::render_positions(&asset_cache, &positions), | ||
); | ||
if let Some(user_price) = user_price { | ||
let adjusted = adjust_price_by_exponents(user_price, &directed_pair); | ||
tracing::debug!(?user_price, ?adjusted, "adjusted price by units"); | ||
return Ok(adjusted); | ||
} else { | ||
tracing::debug!("price not provided, fetching spread"); | ||
|
||
if let Some(debug_file) = &self.debug_file { | ||
Self::write_debug_data( | ||
debug_file.clone(), | ||
self.pair.clone(), | ||
self.input.clone(), | ||
current_price, | ||
positions.clone(), | ||
)?; | ||
return Ok(()); | ||
} | ||
|
||
if !self.yes | ||
&& !Confirm::new() | ||
.with_prompt("Do you want to open those liquidity positions on-chain?") | ||
.interact()? | ||
{ | ||
return Ok(()); | ||
} | ||
|
||
let gas_prices = app | ||
.view | ||
.as_mut() | ||
.context("view service must be initialized")? | ||
.gas_prices(GasPricesRequest {}) | ||
.await? | ||
.into_inner() | ||
.gas_prices | ||
.expect("gas prices must be available") | ||
.try_into()?; | ||
|
||
let mut planner = Planner::new(OsRng); | ||
planner.set_gas_prices(gas_prices); | ||
positions.iter().for_each(|position| { | ||
planner.position_open(position.clone()); | ||
}); | ||
|
||
let plan = planner | ||
.plan( | ||
app.view | ||
.as_mut() | ||
.context("view service must be initialized")?, | ||
AddressIndex::new(self.source), | ||
) | ||
.await?; | ||
let tx_id = app.build_and_submit_transaction(plan).await?; | ||
println!("posted with transaction id: {tx_id}"); | ||
|
||
Ok(()) | ||
} | ||
|
||
fn validate(&self) -> anyhow::Result<()> { | ||
if self.input.asset_id != self.pair.start.id() && self.input.asset_id != self.pair.end.id() | ||
{ | ||
anyhow::bail!("you must supply liquidity with an asset that's part of the market") | ||
} else if self.input.amount == 0u64.into() { | ||
anyhow::bail!("the quantity of liquidity supplied must be non-zero.",) | ||
} else if self.fee_bps > 5000 { | ||
anyhow::bail!("the maximum fee is 5000bps (50%)") | ||
} else if self.current_price.is_some() | ||
&& self.current_price.expect("current price is Some") <= 0.0 | ||
{ | ||
anyhow::bail!("the supplied current price must be positive") | ||
} else { | ||
Ok(()) | ||
} | ||
} | ||
|
||
async fn get_spread(&self, app: &mut App) -> Result<f64> { | ||
let mut client = DexQueryServiceClient::new(app.pd_channel().await?); | ||
let spread_data = client | ||
.spread(SpreadRequest { | ||
trading_pair: Some(self.pair.into_directed_trading_pair().to_canonical().into()), | ||
trading_pair: Some(canonical_pair.into()), | ||
}) | ||
.await? | ||
.into_inner(); | ||
|
||
tracing::debug!( | ||
?spread_data, | ||
pair = self.pair.to_string(), | ||
pair = canonical_pair.to_string(), | ||
"fetched spread for pair" | ||
); | ||
|
||
if spread_data.best_1_to_2_position.is_none() || spread_data.best_2_to_1_position.is_none() | ||
{ | ||
bail!("couldn't find a market price for the specified assets, you can manually specify a price using --current-price <price>") | ||
anyhow::bail!("couldn't find a market price for the specified assets, you can manually specify a price using --current-price <price>") | ||
} | ||
|
||
if self.input.asset_id == self.pair.start.id() { | ||
Ok(spread_data.approx_effective_price_1_to_2) | ||
} else if self.input.asset_id == self.pair.end.id() { | ||
// The price is the amount of asset 2 required to buy 1 unit of asset 1 | ||
|
||
if directed_pair.start.id() == canonical_pair.asset_1() { | ||
Ok(spread_data.approx_effective_price_2_to_1) | ||
} else if directed_pair.start.id() == canonical_pair.asset_2() { | ||
Ok(spread_data.approx_effective_price_1_to_2) | ||
} else { | ||
bail!("the supplied liquidity must be on the pair") | ||
anyhow::bail!("the supplied liquidity must be on the pair") | ||
} | ||
} | ||
} | ||
|
||
pub(crate) fn write_debug_data( | ||
file: PathBuf, | ||
pair: DirectedUnitPair, | ||
input: Value, | ||
current_price: f64, | ||
positions: Vec<Position>, | ||
) -> anyhow::Result<()> { | ||
// Ad-hoc denom scaling for debug data: | ||
let alphas = dex_utils::replicate::xyk::sample_prices( | ||
current_price, | ||
dex_utils::replicate::xyk::NUM_POOLS_PRECISION, | ||
); | ||
|
||
alphas | ||
.iter() | ||
.enumerate() | ||
.for_each(|(i, alpha)| tracing::debug!(i, alpha, "sampled tick")); | ||
|
||
let r1: f64; | ||
|
||
{ | ||
let raw_r1 = input.amount.value(); | ||
let denom_unit = pair.start.unit_amount().value(); | ||
let fp_r1 = U128x128::ratio(raw_r1, denom_unit).expect("denom unit is not 0"); | ||
r1 = fp_r1.into(); | ||
} | ||
fn adjust_price_by_exponents(price: f64, pair: &DirectedUnitPair) -> f64 { | ||
let start_exponent = pair.start.exponent() as i32; | ||
let end_exponent = pair.end.exponent() as i32; | ||
|
||
let r2 = r1 * current_price; | ||
let total_k = r1 * r2; | ||
println!("Entry R1: {r1}"); | ||
println!("Entry R2: {r2}"); | ||
println!("total K: {total_k}"); | ||
price * 10f64.powi(start_exponent - end_exponent) | ||
} | ||
|
||
let debug_positions: Vec<debug::PayoffPositionEntry> = positions | ||
.iter() | ||
.zip(alphas) | ||
.enumerate() | ||
.map(|(idx, (pos, alpha))| { | ||
let payoff_entry = debug::PayoffPosition::from_position(pair.clone(), pos.clone()); | ||
debug::PayoffPositionEntry { | ||
payoff: payoff_entry, | ||
current_price, | ||
index: idx, | ||
pair: pair.clone(), | ||
alpha, | ||
total_k, | ||
} | ||
}) | ||
.collect(); | ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
|
||
let mut fd = std::fs::File::create(&file).map_err(|e| { | ||
anyhow!( | ||
"fs error opening debug file {}: {}", | ||
file.to_string_lossy(), | ||
e | ||
) | ||
})?; | ||
#[test] | ||
fn test_adjust_price_by_exponents() { | ||
let pair1: DirectedUnitPair = "penumbra:gm".parse().unwrap(); | ||
let pair2: DirectedUnitPair = "upenumbra:gm".parse().unwrap(); | ||
let pair3: DirectedUnitPair = "penumbra:ugm".parse().unwrap(); | ||
let pair4: DirectedUnitPair = "upenumbra:ugm".parse().unwrap(); | ||
|
||
let json_data = serde_json::to_string(&debug_positions) | ||
.map_err(|e| anyhow!("error serializing PayoffPositionEntry: {}", e))?; | ||
let base_price = 1.2; | ||
|
||
fd.write_all(json_data.as_bytes()) | ||
.map_err(|e| anyhow!("error writing {}: {}", file.to_string_lossy(), e))?; | ||
Ok(()) | ||
assert_eq!(adjust_price_by_exponents(base_price, &pair1), 1.2); | ||
assert_eq!(adjust_price_by_exponents(base_price, &pair2), 0.0000012); | ||
assert_eq!(adjust_price_by_exponents(base_price, &pair3), 1200000.0); | ||
assert_eq!(adjust_price_by_exponents(base_price, &pair4), 1.2); | ||
} | ||
} |
Oops, something went wrong.