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