1use 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.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]
150 pub fn zero_for_one(&self) -> bool {
151 if self.amount0.is_zero() && self.amount1.is_zero() {
152 self.sqrt_price_after_x96 < self.sqrt_price_before_x96
153 } else {
154 self.amount0.is_positive()
155 }
156 }
157
158 #[must_use]
160 pub fn total_fee(&self) -> U256 {
161 self.lp_fee + self.protocol_fee
162 }
163
164 #[must_use]
166 pub fn get_effective_fee_bps(&self) -> u32 {
167 let input_amount = self.get_input_amount();
168 if input_amount.is_zero() {
169 return 0;
170 }
171
172 let total_fees = self.lp_fee + self.protocol_fee;
173
174 let fee_bps =
176 FullMath::mul_div(total_fees, U256::from(10_000), input_amount).unwrap_or(U256::ZERO);
177
178 fee_bps.to::<u32>()
179 }
180
181 #[must_use]
186 pub fn total_crossed_ticks(&self) -> u32 {
187 self.crossed_ticks.len() as u32
188 }
189
190 #[must_use]
192 pub fn get_output_amount(&self) -> U256 {
193 if self.zero_for_one() {
194 self.amount1.unsigned_abs()
195 } else {
196 self.amount0.unsigned_abs()
197 }
198 }
199
200 #[must_use]
202 pub fn get_input_amount(&self) -> U256 {
203 if self.zero_for_one() {
204 self.amount0.unsigned_abs()
205 } else {
206 self.amount1.unsigned_abs()
207 }
208 }
209
210 pub fn get_price_impact_bps(&mut self) -> anyhow::Result<u32> {
222 match self.check_if_trade_info_initialized() {
223 Ok(trade_info) => trade_info.get_price_impact_bps(),
224 Err(e) => anyhow::bail!("Failed to calculate price impact: {e}"),
225 }
226 }
227
228 pub fn get_slippage_bps(&mut self) -> anyhow::Result<u32> {
240 match self.check_if_trade_info_initialized() {
241 Ok(trade_info) => trade_info.get_slippage_bps(),
242 Err(e) => anyhow::bail!("Failed to calculate slippage: {e}"),
243 }
244 }
245
246 pub fn validate_slippage_tolerance(&mut self, max_slippage_bps: u32) -> anyhow::Result<()> {
250 let actual_slippage = self.get_slippage_bps()?;
251 if actual_slippage > max_slippage_bps {
252 anyhow::bail!(
253 "Slippage {actual_slippage} bps exceeds tolerance {max_slippage_bps} bps"
254 );
255 }
256 Ok(())
257 }
258
259 pub fn validate_exact_output(&self, amount_out_requested: U256) -> anyhow::Result<()> {
264 let actual_out = self.get_output_amount();
265 if actual_out < amount_out_requested {
266 anyhow::bail!(
267 "Insufficient liquidity: requested {amount_out_requested}, available {actual_out}"
268 );
269 }
270 Ok(())
271 }
272
273 #[must_use]
278 #[expect(clippy::too_many_arguments)]
279 pub fn to_swap_event(
280 &self,
281 chain: SharedChain,
282 dex: SharedDex,
283 pool_identifier: PoolIdentifier,
284 block: BlockPosition,
285 ts_event: UnixNanos,
286 ts_init: UnixNanos,
287 sender: Address,
288 recipient: Address,
289 ) -> PoolSwap {
290 let instrument_id = Pool::create_instrument_id(chain.name, &dex, pool_identifier.as_str());
291 PoolSwap::new(
292 chain,
293 dex,
294 instrument_id,
295 pool_identifier,
296 block.number,
297 block.transaction_hash,
298 block.transaction_index,
299 block.log_index,
300 ts_event,
301 ts_init,
302 sender,
303 recipient,
304 self.amount0,
305 self.amount1,
306 self.sqrt_price_after_x96,
307 self.liquidity_after,
308 self.tick_after,
309 )
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use std::str::FromStr;
316
317 use rstest::rstest;
318 use rust_decimal_macros::dec;
319
320 use super::*;
321 use crate::{
322 defi::{SharedPool, stubs::rain_pool},
323 enums::OrderSide,
324 types::{Price, Quantity, fixed::FIXED_PRECISION},
325 };
326
327 #[rstest]
328 fn test_metric_errors_propagate_from_trade_info(rain_pool: SharedPool) {
329 let mut swap_quote = SwapQuote::new(
330 rain_pool.instrument_id,
331 I256::from_str("2").unwrap(),
332 I256::from_str("-1").unwrap(),
333 U160::from(3),
334 U160::from(4),
335 -5,
336 -4,
337 6,
338 U256::from(7),
339 U256::from(8),
340 U256::from(9),
341 vec![],
342 );
343 swap_quote.trade_info = Some(SwapTradeInfo {
344 order_side: OrderSide::Buy,
345 quantity_base: Quantity::from("10"),
346 quantity_quote: Quantity::from("11"),
347 spot_price: Price::from_raw(12, FIXED_PRECISION),
348 execution_price: Price::from_raw(13, FIXED_PRECISION),
349 is_inverted: true,
350 spot_price_before: Some(Price::zero(FIXED_PRECISION)),
351 });
352
353 let price_impact_error = swap_quote.get_price_impact_bps().unwrap_err();
354 let slippage_error = swap_quote.get_slippage_bps().unwrap_err();
355
356 assert_eq!(
357 price_impact_error.to_string(),
358 "Cannot calculate price impact, the spot price before is zero"
359 );
360 assert_eq!(
361 slippage_error.to_string(),
362 "Cannot calculate slippage, the spot price before is zero"
363 );
364 }
365
366 #[rstest]
367 fn test_swap_quote_sell(rain_pool: SharedPool) {
368 let sqrt_x96_price_before = U160::from_str("76951769738874829996307631").unwrap();
370 let amount0 = I256::from_str("287175356684998201516914").unwrap();
371 let amount1 = I256::from_str("-270157537808188649").unwrap();
372
373 let mut swap_quote = SwapQuote::new(
374 rain_pool.instrument_id,
375 amount0,
376 amount1,
377 sqrt_x96_price_before,
378 U160::from_str("76812046714213096298497129").unwrap(),
379 -138_746,
380 -138_782,
381 292_285_495_328_044_734_302_670,
382 U256::ZERO,
383 U256::ZERO,
384 U256::ZERO,
385 vec![],
386 );
387 swap_quote
388 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
389 .unwrap();
390
391 if let Some(swap_trade_info) = &swap_quote.trade_info {
392 assert_eq!(swap_trade_info.order_side, OrderSide::Sell);
393 assert_eq!(swap_quote.get_input_amount(), amount0.unsigned_abs());
394 assert_eq!(swap_quote.get_output_amount(), amount1.unsigned_abs());
395 assert_eq!(
397 swap_trade_info.quantity_base.as_decimal(),
398 dec!(287175.356684998201516914)
399 );
400 assert_eq!(
401 swap_trade_info.quantity_quote.as_decimal(),
402 dec!(0.270157537808188649)
403 );
404 assert_eq!(
405 swap_trade_info.spot_price.as_decimal(),
406 dec!(0.0000009399386483)
407 );
408 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 36);
409 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 28);
410 } else {
411 panic!("Trade info is None");
412 }
413 }
414
415 #[rstest]
416 fn test_swap_quote_buy(rain_pool: SharedPool) {
417 let sqrt_x96_price_before = U160::from_str("76827576486429933391429745").unwrap();
419 let amount0 = I256::from_str("-117180628248242869089291").unwrap();
420 let amount1 = I256::from_str("110241020399788696").unwrap();
421
422 let mut swap_quote = SwapQuote::new(
423 rain_pool.instrument_id,
424 amount0,
425 amount1,
426 sqrt_x96_price_before,
427 U160::from_str("76857455902960072891859299").unwrap(),
428 -138_778,
429 -138_770,
430 292_285_495_328_044_734_302_670,
431 U256::ZERO,
432 U256::ZERO,
433 U256::ZERO,
434 vec![],
435 );
436 swap_quote
437 .calculate_trade_info(&rain_pool.token0, &rain_pool.token1)
438 .unwrap();
439
440 if let Some(swap_trade_info) = &swap_quote.trade_info {
441 assert_eq!(swap_trade_info.order_side, OrderSide::Buy);
442 assert_eq!(swap_quote.get_input_amount(), amount1.unsigned_abs());
443 assert_eq!(swap_quote.get_output_amount(), amount0.unsigned_abs());
444 assert_eq!(
446 swap_trade_info.quantity_base.as_decimal(),
447 dec!(117180.628248242869089291)
448 );
449 assert_eq!(
450 swap_trade_info.quantity_quote.as_decimal(),
451 dec!(0.110241020399788696)
452 );
453 assert_eq!(
454 swap_trade_info.spot_price.as_decimal(),
455 dec!(0.000000941050309)
456 );
457 assert_eq!(
458 swap_trade_info.execution_price.as_decimal(),
459 dec!(0.0000009407785403)
460 );
461 assert_eq!(swap_trade_info.get_price_impact_bps().unwrap(), 8);
462 assert_eq!(swap_trade_info.get_slippage_bps().unwrap(), 5);
463 } else {
464 panic!("Trade info is None");
465 }
466 }
467}