nautilus_model/defi/pool_analysis/
quote.rs1use alloy_primitives::{Address, I256, U160, U256};
17
18use crate::{
19 defi::{
20 Pool, PoolIdentifier, PoolSwap, SharedChain, SharedDex, Token,
21 data::{
22 block::BlockPosition,
23 swap::RawSwapData,
24 swap_trade_info::{SwapTradeInfo, SwapTradeInfoCalculator},
25 },
26 tick_map::{full_math::FullMath, tick::CrossedTick},
27 },
28 identifiers::InstrumentId,
29};
30
31#[derive(Debug, Clone)]
37#[cfg_attr(
38 feature = "python",
39 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
40)]
41#[cfg_attr(
42 feature = "python",
43 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
44)]
45pub struct SwapQuote {
46 pub instrument_id: InstrumentId,
48 pub amount0: I256,
50 pub amount1: I256,
52 pub sqrt_price_before_x96: U160,
54 pub sqrt_price_after_x96: U160,
56 pub tick_before: i32,
58 pub tick_after: i32,
60 pub liquidity_after: u128,
62 pub fee_growth_global_after: U256,
64 pub lp_fee: U256,
66 pub protocol_fee: U256,
68 pub crossed_ticks: Vec<CrossedTick>,
70 pub trade_info: Option<SwapTradeInfo>,
72}
73
74impl SwapQuote {
75 #[expect(clippy::too_many_arguments)]
76 #[must_use]
82 pub fn new(
83 instrument_id: InstrumentId,
84 amount0: I256,
85 amount1: I256,
86 sqrt_price_before_x96: U160,
87 sqrt_price_after_x96: U160,
88 tick_before: i32,
89 tick_after: i32,
90 liquidity_after: u128,
91 fee_growth_global_after: U256,
92 lp_fee: U256,
93 protocol_fee: U256,
94 crossed_ticks: Vec<CrossedTick>,
95 ) -> Self {
96 Self {
97 instrument_id,
98 amount0,
99 amount1,
100 sqrt_price_before_x96,
101 sqrt_price_after_x96,
102 tick_before,
103 tick_after,
104 liquidity_after,
105 fee_growth_global_after,
106 lp_fee,
107 protocol_fee,
108 crossed_ticks,
109 trade_info: None,
110 }
111 }
112
113 fn check_if_trade_info_initialized(&self) -> anyhow::Result<&SwapTradeInfo> {
114 if self.trade_info.is_none() {
115 anyhow::bail!(
116 "Trade info is not initialized. Please call calculate_trade_info() first."
117 );
118 }
119
120 Ok(self.trade_info.as_ref().unwrap())
121 }
122
123 pub fn calculate_trade_info(&mut self, token0: &Token, token1: &Token) -> anyhow::Result<()> {
133 let trade_info_calculator = SwapTradeInfoCalculator::new(
134 token0,
135 token1,
136 RawSwapData::new(self.amount0, self.amount1, self.sqrt_price_after_x96),
137 );
138 let trade_info = trade_info_calculator.compute(Some(self.sqrt_price_before_x96))?;
139 self.trade_info = Some(trade_info);
140
141 Ok(())
142 }
143
144 #[must_use]
148 pub fn zero_for_one(&self) -> bool {
149 self.amount0.is_positive()
150 }
151
152 #[must_use]
154 pub fn total_fee(&self) -> U256 {
155 self.lp_fee + self.protocol_fee
156 }
157
158 #[must_use]
160 pub fn get_effective_fee_bps(&self) -> u32 {
161 let input_amount = self.get_input_amount();
162 if input_amount.is_zero() {
163 return 0;
164 }
165
166 let total_fees = self.lp_fee + self.protocol_fee;
167
168 let fee_bps =
170 FullMath::mul_div(total_fees, U256::from(10_000), input_amount).unwrap_or(U256::ZERO);
171
172 fee_bps.to::<u32>()
173 }
174
175 #[must_use]
180 pub fn total_crossed_ticks(&self) -> u32 {
181 self.crossed_ticks.len() as u32
182 }
183
184 #[must_use]
186 pub fn get_output_amount(&self) -> U256 {
187 if self.zero_for_one() {
188 self.amount1.unsigned_abs()
189 } else {
190 self.amount0.unsigned_abs()
191 }
192 }
193
194 #[must_use]
196 pub fn get_input_amount(&self) -> U256 {
197 if self.zero_for_one() {
198 self.amount0.unsigned_abs()
199 } else {
200 self.amount1.unsigned_abs()
201 }
202 }
203
204 pub fn get_price_impact_bps(&mut self) -> anyhow::Result<u32> {
216 match self.check_if_trade_info_initialized() {
217 Ok(trade_info) => trade_info.get_price_impact_bps(),
218 Err(e) => anyhow::bail!("Failed to calculate price impact: {e}"),
219 }
220 }
221
222 pub fn get_slippage_bps(&mut self) -> anyhow::Result<u32> {
234 match self.check_if_trade_info_initialized() {
235 Ok(trade_info) => trade_info.get_slippage_bps(),
236 Err(e) => anyhow::bail!("Failed to calculate slippage: {e}"),
237 }
238 }
239
240 pub fn validate_slippage_tolerance(&mut self, max_slippage_bps: u32) -> anyhow::Result<()> {
244 let actual_slippage = self.get_slippage_bps()?;
245 if actual_slippage > max_slippage_bps {
246 anyhow::bail!(
247 "Slippage {actual_slippage} bps exceeds tolerance {max_slippage_bps} bps"
248 );
249 }
250 Ok(())
251 }
252
253 pub fn validate_exact_output(&self, amount_out_requested: U256) -> anyhow::Result<()> {
258 let actual_out = self.get_output_amount();
259 if actual_out < amount_out_requested {
260 anyhow::bail!(
261 "Insufficient liquidity: requested {amount_out_requested}, available {actual_out}"
262 );
263 }
264 Ok(())
265 }
266
267 #[must_use]
272 pub fn to_swap_event(
273 &self,
274 chain: SharedChain,
275 dex: SharedDex,
276 pool_identifier: PoolIdentifier,
277 block: BlockPosition,
278 sender: Address,
279 recipient: Address,
280 ) -> PoolSwap {
281 let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
282 PoolSwap::new(
283 chain,
284 dex,
285 instrument_id,
286 pool_identifier,
287 block.number,
288 block.transaction_hash,
289 block.transaction_index,
290 block.log_index,
291 None, sender,
293 recipient,
294 self.amount0,
295 self.amount1,
296 self.sqrt_price_after_x96,
297 self.liquidity_after,
298 self.tick_after,
299 )
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use std::str::FromStr;
306
307 use rstest::rstest;
308 use rust_decimal_macros::dec;
309
310 use super::*;
311 use crate::{
312 defi::{SharedPool, stubs::rain_pool},
313 enums::OrderSide,
314 };
315
316 #[rstest]
317 fn test_swap_quote_sell(rain_pool: SharedPool) {
318 let sqrt_x96_price_before = U160::from_str("76951769738874829996307631").unwrap();
320 let amount0 = I256::from_str("287175356684998201516914").unwrap();
321 let amount1 = I256::from_str("-270157537808188649").unwrap();
322
323 let mut swap_quote = SwapQuote::new(
324 rain_pool.instrument_id,
325 amount0,
326 amount1,
327 sqrt_x96_price_before,
328 U160::from_str("76812046714213096298497129").unwrap(),
329 -138_746,
330 -138_782,
331 292_285_495_328_044_734_302_670,
332 U256::ZERO,
333 U256::ZERO,
334 U256::ZERO,
335 vec![],
336 );
337 swap_quote
338 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
339 .unwrap();
340
341 if let Some(swap_trade_info) = &swap_quote.trade_info {
342 assert_eq!(swap_trade_info.order_side, OrderSide::Sell);
343 assert_eq!(swap_quote.get_input_amount(), amount0.unsigned_abs());
344 assert_eq!(swap_quote.get_output_amount(), amount1.unsigned_abs());
345 assert_eq!(
347 swap_trade_info.quantity_base.as_decimal(),
348 dec!(287175.356684998201516914)
349 );
350 assert_eq!(
351 swap_trade_info.quantity_quote.as_decimal(),
352 dec!(0.270157537808188649)
353 );
354 assert_eq!(
355 swap_trade_info.spot_price.as_decimal(),
356 dec!(0.0000009399386483)
357 );
358 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 36);
359 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 28);
360 } else {
361 panic!("Trade info is None");
362 }
363 }
364
365 #[rstest]
366 fn test_swap_quote_buy(rain_pool: SharedPool) {
367 let sqrt_x96_price_before = U160::from_str("76827576486429933391429745").unwrap();
369 let amount0 = I256::from_str("-117180628248242869089291").unwrap();
370 let amount1 = I256::from_str("110241020399788696").unwrap();
371
372 let mut swap_quote = SwapQuote::new(
373 rain_pool.instrument_id,
374 amount0,
375 amount1,
376 sqrt_x96_price_before,
377 U160::from_str("76857455902960072891859299").unwrap(),
378 -138_778,
379 -138_770,
380 292_285_495_328_044_734_302_670,
381 U256::ZERO,
382 U256::ZERO,
383 U256::ZERO,
384 vec![],
385 );
386 swap_quote
387 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
388 .unwrap();
389
390 if let Some(swap_trade_info) = &swap_quote.trade_info {
391 assert_eq!(swap_trade_info.order_side, OrderSide::Buy);
392 assert_eq!(swap_quote.get_input_amount(), amount1.unsigned_abs());
393 assert_eq!(swap_quote.get_output_amount(), amount0.unsigned_abs());
394 assert_eq!(
396 swap_trade_info.quantity_base.as_decimal(),
397 dec!(117180.628248242869089291)
398 );
399 assert_eq!(
400 swap_trade_info.quantity_quote.as_decimal(),
401 dec!(0.110241020399788696)
402 );
403 assert_eq!(
404 swap_trade_info.spot_price.as_decimal(),
405 dec!(0.000000941050309)
406 );
407 assert_eq!(
408 swap_trade_info.execution_price.as_decimal(),
409 dec!(0.0000009407785403)
410 );
411 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 8);
412 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 5);
413 } else {
414 panic!("Trade info is None");
415 }
416 }
417}