From 12191b16946458510d6fb9af4cacd6f51828b64c Mon Sep 17 00:00:00 2001 From: Henry de Valence Date: Sat, 10 Aug 2024 12:43:05 -0400 Subject: [PATCH] pcli: wip linear liquidity strategies --- crates/bin/pcli/src/command/tx/replicate.rs | 297 +++------------- .../pcli/src/command/tx/replicate/linear.rs | 332 ++++++++++++++++++ .../bin/pcli/src/command/tx/replicate/xyk.rs | 236 +++++++++++++ 3 files changed, 624 insertions(+), 241 deletions(-) create mode 100644 crates/bin/pcli/src/command/tx/replicate/linear.rs create mode 100644 crates/bin/pcli/src/command/tx/replicate/xyk.rs diff --git a/crates/bin/pcli/src/command/tx/replicate.rs b/crates/bin/pcli/src/command/tx/replicate.rs index 292f25ee3a..98669824b2 100644 --- a/crates/bin/pcli/src/command/tx/replicate.rs +++ b/crates/bin/pcli/src/command/tx/replicate.rs @@ -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, - - #[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, - #[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, + directed_pair: DirectedUnitPair, +) -> anyhow::Result { + 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 { 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 ") + anyhow::bail!("couldn't find a market price for the specified assets, you can manually specify a price using --current-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, - ) -> 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 = 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); } } diff --git a/crates/bin/pcli/src/command/tx/replicate/linear.rs b/crates/bin/pcli/src/command/tx/replicate/linear.rs new file mode 100644 index 0000000000..90885954cb --- /dev/null +++ b/crates/bin/pcli/src/command/tx/replicate/linear.rs @@ -0,0 +1,332 @@ +use anyhow::Context; +use dialoguer::Confirm; +use rand_core::{CryptoRngCore, OsRng}; + +use penumbra_asset::Value; +use penumbra_dex::{ + lp::{position::Position, Reserves}, + DirectedUnitPair, +}; +use penumbra_keys::keys::AddressIndex; +use penumbra_num::Amount; +use penumbra_proto::view::v1::GasPricesRequest; +use penumbra_view::{Planner, ViewClient}; + +use crate::App; + +#[derive(Debug, Clone, clap::Args)] +pub struct Linear { + /// The pair to provide liquidity for. + pub pair: DirectedUnitPair, + + /// The target amount of liquidity (in asset 2) to provide. + /// + /// Note that the actual amount of liquidity provided will be a mix of + /// asset 1 and asset 2, depending on the current price. + pub input: Value, + + /// The lower bound of the price range. + /// + /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1. + #[clap(short, long, display_order = 100)] + pub lower_price: f64, + /// The upper bound of the price range. + /// + /// Prices are the amount of asset 2 required to purchase 1 unit of asset 1. + #[clap(short, long, display_order = 101)] + pub upper_price: f64, + + /// The percentage fee to apply to each trade, expressed in basis points. + #[clap(short, long, default_value_t = 50u32, display_order = 200)] + pub fee_bps: u32, + + /// The number of positions to create. + #[clap(short, long, default_value_t = 16, display_order = 300)] + pub num_positions: u32, + + /// The current price. If not provided, the current price is fetched from + /// the chain. + /// + /// This is used to determine which positions should be funded with asset 1 + /// and which positions should be funded with asset 2. + #[clap(short, long, display_order = 400)] + pub current_price: Option, + + /// Closes positions on fill, for executing trades on the maker side. + /// + /// Not recommended for liquidity provision + #[clap(long, default_value_t = false, display_order = 500)] + pub close_on_fill: bool, + + /// `--yes` means all prompt interaction are skipped and agreed. + #[clap(short, long, display_order = 501)] + pub yes: bool, + + /// The account to use to fund the LPs and store the LP tokens. + #[clap(long, default_value = "0", display_order = 503)] + pub source: u32, +} + +impl Linear { + pub async fn exec(&self, app: &mut App) -> anyhow::Result<()> { + self.validate()?; + + let pair = self.pair.clone(); + + tracing::debug!(start = ?pair.start.base()); + tracing::debug!(end = ?pair.end.base()); + + let mut asset_cache = app.view().assets().await?; + if !asset_cache.contains_key(&pair.start.id()) { + asset_cache.extend(std::iter::once(pair.start.base())); + } + if !asset_cache.contains_key(&pair.end.id()) { + asset_cache.extend(std::iter::once(pair.end.base())); + } + + let current_price = + super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone()) + .await?; + + tracing::debug!(?self); + tracing::debug!(?current_price); + + let positions = self.build_positions(OsRng, current_price, self.input); + + let (amount_start, amount_end) = + positions + .iter() + .fold((Amount::zero(), Amount::zero()), |acc, pos| { + tracing::debug!(?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); + + 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); + + println!("You will create the following positions:"); + println!( + "{}", + crate::command::utils::render_positions(&asset_cache, &positions), + ); + + 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 build_positions( + &self, + mut rng: R, + current_price: f64, + input: Value, + ) -> Vec { + // The step width is num_positions-1 because it's between the endpoints + // |---|---|---|---| + // 0 1 2 3 4 + // 0 1 2 3 + let step_width = (self.upper_price - self.lower_price) / (self.num_positions - 1) as f64; + + // We are treating asset 2 as the numeraire and want to have an even spread + // of asset 2 value across all positions. + let total_input = input.amount.value() as f64; + let asset_2_per_position = total_input / self.num_positions as f64; + + tracing::debug!( + ?current_price, + ?step_width, + ?total_input, + ?asset_2_per_position + ); + + let mut positions = vec![]; + + let dtp = self.pair.into_directed_trading_pair(); + + for i in 0..self.num_positions { + let position_price = self.lower_price + step_width * i as f64; + + // Cross-multiply exponents and prices for trading function coefficients + // + // We want to write + // p = EndUnit * price + // q = StartUnit + // However, if EndUnit is too small, it might not round correctly after multiplying by price + // To handle this, conditionally apply a scaling factor if the EndUnit amount is too small. + let scale = if self.pair.end.unit_amount().value() < 1_000_000 { + 1_000_000 + } else { + 1 + }; + + let p = Amount::from( + ((self.pair.end.unit_amount().value() * scale) as f64 * position_price) as u128, + ); + let q = self.pair.start.unit_amount() * Amount::from(scale); + + // Compute reserves + let reserves = if position_price < current_price { + // If the position's price is _less_ than the current price, fund it with asset 2 + // so the position isn't immediately arbitraged. + Reserves { + r1: Amount::zero(), + r2: Amount::from(asset_2_per_position as u128), + } + } else { + // If the position's price is _greater_ than the current price, fund it with + // an equivalent amount of asset 1 as the target per-position amount of asset 2. + let asset_1 = asset_2_per_position / position_price; + Reserves { + r1: Amount::from(asset_1 as u128), + r2: Amount::zero(), + } + }; + + let position = Position::new(&mut rng, dtp, self.fee_bps, p, q, reserves); + + positions.push(position); + } + + positions + } + + fn validate(&self) -> anyhow::Result<()> { + if self.input.asset_id != self.pair.end.id() { + anyhow::bail!("liquidity target is specified in terms of asset 2 but provided input is for a different asset") + } 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 if self.lower_price >= self.upper_price { + anyhow::bail!("the lower price must be less than the upper price") + } else if self.num_positions <= 2 { + anyhow::bail!("the number of positions must be greater than 2") + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + use super::*; + + #[test] + fn sanity_check_penumbra_gm_example() { + let params = Linear { + pair: "penumbra:gm".parse().unwrap(), + input: "1000gm".parse().unwrap(), + lower_price: 1.8, + upper_price: 2.2, + fee_bps: 50, + num_positions: 5, + current_price: Some(2.05), + close_on_fill: false, + yes: false, + source: 0, + }; + + let mut rng = ChaCha20Rng::seed_from_u64(12345); + + let positions = params.build_positions( + &mut rng, + params.current_price.unwrap(), + params.input.clone(), + ); + + for position in &positions { + dbg!(position); + } + + let asset_cache = penumbra_asset::asset::Cache::with_known_assets(); + + dbg!(¶ms); + println!( + "{}", + crate::command::utils::render_positions(&asset_cache, &positions), + ); + + for position in &positions { + let id = position.id(); + let buy = position.interpret_as_buy().unwrap(); + let sell = position.interpret_as_sell().unwrap(); + println!("{}: BUY {}", id, buy.format(&asset_cache).unwrap()); + println!("{}: SELL {}", id, sell.format(&asset_cache).unwrap()); + } + + let um_id = params.pair.start.id(); + let gm_id = params.pair.end.id(); + + assert_eq!(positions.len(), 5); + // These should be all GM + assert_eq!(positions[0].reserves_for(um_id).unwrap(), 0u64.into()); + assert_eq!(positions[1].reserves_for(um_id).unwrap(), 0u64.into()); + assert_eq!(positions[2].reserves_for(um_id).unwrap(), 0u64.into()); + // These should be all UM + assert_eq!(positions[3].reserves_for(gm_id).unwrap(), 0u64.into()); + assert_eq!(positions[4].reserves_for(gm_id).unwrap(), 0u64.into()); + } +} diff --git a/crates/bin/pcli/src/command/tx/replicate/xyk.rs b/crates/bin/pcli/src/command/tx/replicate/xyk.rs new file mode 100644 index 0000000000..23d0508925 --- /dev/null +++ b/crates/bin/pcli/src/command/tx/replicate/xyk.rs @@ -0,0 +1,236 @@ +use std::io::Write; +use std::path::PathBuf; + +use anyhow::{anyhow, Context}; +use dialoguer::Confirm; +use rand_core::OsRng; + +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::view::v1::GasPricesRequest; +use penumbra_view::{Planner, ViewClient}; + +use crate::dex_utils; +use crate::dex_utils::replicate::debug; +use crate::{warning, App}; + +#[derive(Debug, Clone, clap::Args)] +pub struct ConstantProduct { + pub pair: DirectedUnitPair, + pub input: Value, + + #[clap(short, long)] + pub current_price: Option, + + #[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, + #[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 = + super::process_price_or_fetch_spread(app, self.current_price, self.pair.clone()) + .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(); + + 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 positions:"); + let asset_cache = app.view().assets().await?; + println!( + "{}", + crate::command::utils::render_positions(&asset_cache, &positions), + ); + + 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(()) + } + } + + pub(crate) fn write_debug_data( + file: PathBuf, + pair: DirectedUnitPair, + input: Value, + current_price: f64, + positions: Vec, + ) -> 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(); + } + + let r2 = r1 * current_price; + let total_k = r1 * r2; + println!("Entry R1: {r1}"); + println!("Entry R2: {r2}"); + println!("total K: {total_k}"); + + let debug_positions: Vec = 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(); + + let mut fd = std::fs::File::create(&file).map_err(|e| { + anyhow!( + "fs error opening debug file {}: {}", + file.to_string_lossy(), + e + ) + })?; + + let json_data = serde_json::to_string(&debug_positions) + .map_err(|e| anyhow!("error serializing PayoffPositionEntry: {}", e))?; + + fd.write_all(json_data.as_bytes()) + .map_err(|e| anyhow!("error writing {}: {}", file.to_string_lossy(), e))?; + Ok(()) + } +}