Skip to main content

tycho_simulation/price_level_stream/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use num_bigint::BigUint;
4use num_traits::{CheckedSub, ToPrimitive};
5use serde::{Deserialize, Serialize};
6use tycho_common::{
7    dto::ProtocolStateDelta,
8    models::token::Token,
9    simulation::{
10        errors::{SimulationError, TransitionError},
11        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
12    },
13    Bytes,
14};
15
16/// A single price level: the total `amount_out` a swap of exactly `amount_in` would deliver.
17///
18/// Levels are absolute quotes, not marginal order book sizes: each one already includes all
19/// smaller levels' liquidity.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct PriceLevelStreamQuote {
22    pub amount_in: BigUint,
23    pub amount_out: BigUint,
24}
25
26impl PriceLevelStreamQuote {
27    pub fn new(amount_in: BigUint, amount_out: BigUint) -> Self {
28        Self { amount_in, amount_out }
29    }
30}
31
32/// State of a single pAMM pair fed from the price level stream.
33///
34/// Holds the latest complete quote ladders for both trade directions. Quotes are absolute
35/// (`amount_in` → total `amount_out`), sorted ascending by `amount_in`; amounts between two
36/// quotes are interpolated linearly, mirroring how Titan itself densifies the simulated levels.
37/// Amounts outside the quoted range are not served.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PriceLevelStreamState {
40    pub token0: Bytes,
41    pub token1: Bytes,
42    pub quotes_0_to_1: Vec<PriceLevelStreamQuote>,
43    pub quotes_1_to_0: Vec<PriceLevelStreamQuote>,
44    pub gas_cost: BigUint,
45}
46
47impl PriceLevelStreamState {
48    /// Creates the state for the pair `(token0, token1)`.
49    ///
50    /// Both quote ladders are sorted ascending by `amount_in` and deduplicated on it, so callers
51    /// may pass them in stream order.
52    pub fn new(
53        token0: Bytes,
54        token1: Bytes,
55        mut quotes_0_to_1: Vec<PriceLevelStreamQuote>,
56        mut quotes_1_to_0: Vec<PriceLevelStreamQuote>,
57        gas_cost: BigUint,
58    ) -> Self {
59        for quotes in [&mut quotes_0_to_1, &mut quotes_1_to_0] {
60            quotes.sort_by(|a, b| a.amount_in.cmp(&b.amount_in));
61            quotes.dedup_by(|a, b| a.amount_in == b.amount_in);
62        }
63        Self { token0, token1, quotes_0_to_1, quotes_1_to_0, gas_cost }
64    }
65
66    /// Returns the quote ladder selling `token_in` for `token_out`, or an error if the pair does
67    /// not match this state's tokens.
68    fn quotes(
69        &self,
70        token_in: &Bytes,
71        token_out: &Bytes,
72    ) -> Result<&[PriceLevelStreamQuote], SimulationError> {
73        if token_in == &self.token0 && token_out == &self.token1 {
74            Ok(&self.quotes_0_to_1)
75        } else if token_in == &self.token1 && token_out == &self.token0 {
76            Ok(&self.quotes_1_to_0)
77        } else {
78            Err(SimulationError::RecoverableError(format!(
79                "Invalid token addresses for pair {}/{}: {token_in}, {token_out}",
80                self.token0, self.token1
81            )))
82        }
83    }
84
85    /// Computes the output amount for `amount_in` on the given ladder by linear interpolation
86    /// between the two enclosing quotes.
87    ///
88    /// Callers must ensure `amount_in` lies within the quoted range (smallest to largest
89    /// `amount_in`) — the ladder holds no information outside of it. Errors if the enclosing
90    /// quotes are not monotonically increasing in `amount_out`: such a ladder is unreliable,
91    /// and a venue with corrupt data should not be quoted at any price.
92    fn interpolate(
93        &self,
94        quotes: &[PriceLevelStreamQuote],
95        amount_in: &BigUint,
96    ) -> Result<BigUint, SimulationError> {
97        // First quote with amount_in >= the requested amount; the caller-guaranteed range makes
98        // both it and (when needed) its predecessor exist.
99        let idx = quotes.partition_point(|quote| &quote.amount_in < amount_in);
100        let upper = &quotes[idx];
101        if &upper.amount_in == amount_in {
102            return Ok(upper.amount_out.clone());
103        }
104        let lower = &quotes[idx - 1];
105        let Some(out_span) = upper
106            .amount_out
107            .checked_sub(&lower.amount_out)
108        else {
109            // Recoverable: the next snapshot replaces the ladder wholesale.
110            return Err(SimulationError::RecoverableError(format!(
111                "Quote ladder {}/{} is not monotonically increasing in amount_out around the \
112                 requested amount {amount_in}: {} -> {}, but {} -> {}",
113                self.token0,
114                self.token1,
115                lower.amount_in,
116                lower.amount_out,
117                upper.amount_in,
118                upper.amount_out,
119            )));
120        };
121        let in_span = &upper.amount_in - &lower.amount_in;
122        let offset = amount_in - &lower.amount_in;
123        Ok(&lower.amount_out + out_span * offset / in_span)
124    }
125
126    /// The state after a fill: both ladders are consumed. The snapshot quotes fills of the
127    /// pre-fill venue only — post-fill pricing is unknown in either direction until the next
128    /// snapshot, and re-reading the cumulative ladder would double-count the maker's liquidity.
129    fn consumed(&self) -> Box<dyn ProtocolSim> {
130        Box::new(Self {
131            token0: self.token0.clone(),
132            token1: self.token1.clone(),
133            quotes_0_to_1: Vec::new(),
134            quotes_1_to_0: Vec::new(),
135            gas_cost: self.gas_cost.clone(),
136        })
137    }
138}
139
140#[typetag::serde]
141impl ProtocolSim for PriceLevelStreamState {
142    fn fee(&self) -> f64 {
143        0.0
144    }
145
146    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
147        let quotes = self.quotes(&base.address, &quote.address)?;
148        let best = quotes
149            .iter()
150            .find(|q| q.amount_in > BigUint::ZERO && q.amount_out > BigUint::ZERO)
151            .ok_or_else(|| {
152                SimulationError::RecoverableError("No liquidity available".to_string())
153            })?;
154        let amount_in = best.amount_in.to_f64().ok_or_else(|| {
155            SimulationError::RecoverableError("Can't convert amount in to f64".to_string())
156        })?;
157        let amount_out = best
158            .amount_out
159            .to_f64()
160            .ok_or_else(|| {
161                SimulationError::RecoverableError("Can't convert amount out to f64".to_string())
162            })?;
163        Ok((amount_out / 10f64.powi(quote.decimals as i32)) /
164            (amount_in / 10f64.powi(base.decimals as i32)))
165    }
166
167    fn get_amount_out(
168        &self,
169        amount_in: BigUint,
170        token_in: &Token,
171        token_out: &Token,
172    ) -> Result<GetAmountOutResult, SimulationError> {
173        let quotes = self.quotes(&token_in.address, &token_out.address)?;
174        let (Some(first), Some(last)) = (quotes.first(), quotes.last()) else {
175            return Err(SimulationError::RecoverableError("No liquidity available".to_string()));
176        };
177        // Below the smallest quote nothing is served. The venue itself could still fill —
178        // FermiSwap was observed quoting below the smallest streamed level — but if so, on its
179        // own price curve, which the ladder holds no information about: unlike interpolation
180        // between two quoted levels, whose result is bracketed by genuine samples on both
181        // sides, extrapolating linearly from (0, 0) like Titan's quote API does is an unbounded
182        // guess even off a healthy ladder. (0, 0) is an assumption, not a sample, and the
183        // bottom of that line is not even fillable: FermiSwap reverts below a venue-side
184        // minimum (~$0.02 at the time of measurement) where the API keeps quoting. And a
185        // malformed ladder turns the guess absurd: on FermiSwap's flat cbBTC-input books
186        // (18-decimals grid bug) the smallest quote is the venue's depth clamp, not a price
187        // sample, and the extrapolated quotes land ~3e6x below the venue's own (measured
188        // 2026-07). All the rejection gives up are trades smaller than the first level —
189        // ~$10-30 on healthy ladders, negligible for routing. Hence no partial result either.
190        if amount_in < first.amount_in {
191            return Err(SimulationError::InvalidInput(
192                format!(
193                    "Input amount is below the smallest quote. input amount: {amount_in}, minimum quoted amount: {}",
194                    first.amount_in
195                ),
196                None,
197            ));
198        }
199        // The requested amount exceeds the largest quote; report the output at the limit as a
200        // partial result, like other level-based protocols do.
201        if amount_in > last.amount_in {
202            let res = GetAmountOutResult {
203                amount: last.amount_out.clone(),
204                gas: self.gas_cost.clone(),
205                new_state: self.consumed(),
206            };
207            return Err(SimulationError::InvalidInput(
208                format!(
209                    "Not enough liquidity to support complete swap. input amount: {amount_in}, maximum quoted amount: {}",
210                    last.amount_in
211                ),
212                Some(res),
213            ));
214        }
215        Ok(GetAmountOutResult {
216            amount: self.interpolate(quotes, &amount_in)?,
217            gas: self.gas_cost.clone(),
218            new_state: self.consumed(),
219        })
220    }
221
222    fn get_limits(
223        &self,
224        sell_token: Bytes,
225        buy_token: Bytes,
226    ) -> Result<(BigUint, BigUint), SimulationError> {
227        let quotes = self.quotes(&sell_token, &buy_token)?;
228        match quotes.last() {
229            Some(largest) => Ok((largest.amount_in.clone(), largest.amount_out.clone())),
230            None => Ok((BigUint::ZERO, BigUint::ZERO)),
231        }
232    }
233
234    fn delta_transition(
235        &mut self,
236        _delta: ProtocolStateDelta,
237        _tokens: &HashMap<Bytes, Token>,
238        _balances: &Balances,
239    ) -> Result<(), TransitionError> {
240        Err(TransitionError::DecodeError("Not implemented".into()))
241    }
242
243    fn clone_box(&self) -> Box<dyn ProtocolSim> {
244        Box::new(self.clone())
245    }
246
247    fn as_any(&self) -> &dyn Any {
248        self
249    }
250
251    fn as_any_mut(&mut self) -> &mut dyn Any {
252        self
253    }
254
255    fn eq(&self, other: &dyn ProtocolSim) -> bool {
256        other
257            .as_any()
258            .downcast_ref::<PriceLevelStreamState>()
259            .is_some_and(|other| {
260                let Self { token0, token1, quotes_0_to_1, quotes_1_to_0, gas_cost } = other;
261                &self.token0 == token0 &&
262                    &self.token1 == token1 &&
263                    &self.quotes_0_to_1 == quotes_0_to_1 &&
264                    &self.quotes_1_to_0 == quotes_1_to_0 &&
265                    &self.gas_cost == gas_cost
266            })
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use std::str::FromStr;
273
274    use tycho_common::models::Chain;
275
276    use super::*;
277
278    fn wbtc() -> Token {
279        Token::new(
280            &Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(),
281            "WBTC",
282            8,
283            0,
284            &[Some(10_000)],
285            Chain::Ethereum,
286            100,
287        )
288    }
289
290    fn usdc() -> Token {
291        Token::new(
292            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
293            "USDC",
294            6,
295            0,
296            &[Some(10_000)],
297            Chain::Ethereum,
298            100,
299        )
300    }
301
302    fn weth() -> Token {
303        Token::new(
304            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
305            "WETH",
306            18,
307            0,
308            &[Some(10_000)],
309            Chain::Ethereum,
310            100,
311        )
312    }
313
314    fn quote(amount_in: u64, amount_out: u64) -> PriceLevelStreamQuote {
315        PriceLevelStreamQuote::new(BigUint::from(amount_in), BigUint::from(amount_out))
316    }
317
318    /// WBTC (token0) / USDC (token1) ladder: 1 WBTC -> 100k USDC flat, then the second level
319    /// fills at a worse marginal price.
320    fn state() -> PriceLevelStreamState {
321        PriceLevelStreamState::new(
322            wbtc().address,
323            usdc().address,
324            vec![quote(100_000_000, 100_000_000_000), quote(200_000_000, 190_000_000_000)],
325            vec![quote(100_000_000_000, 99_000_000), quote(200_000_000_000, 190_000_000)],
326            BigUint::from(120_000u64),
327        )
328    }
329
330    #[test]
331    fn new_sorts_and_dedups_quotes() {
332        let state = PriceLevelStreamState::new(
333            wbtc().address,
334            usdc().address,
335            vec![quote(200, 380), quote(100, 200), quote(200, 999)],
336            vec![],
337            BigUint::ZERO,
338        );
339        assert_eq!(state.quotes_0_to_1, vec![quote(100, 200), quote(200, 380)]);
340    }
341
342    #[test]
343    fn get_amount_out_exact_level() {
344        let result = state()
345            .get_amount_out(BigUint::from(100_000_000u64), &wbtc(), &usdc())
346            .unwrap();
347        assert_eq!(result.amount, BigUint::from(100_000_000_000u64));
348        assert_eq!(result.gas, BigUint::from(120_000u64));
349    }
350
351    #[test]
352    fn get_amount_out_interpolates_between_levels() {
353        // Halfway between the two levels: 100k + (190k - 100k) / 2 = 145k USDC.
354        let result = state()
355            .get_amount_out(BigUint::from(150_000_000u64), &wbtc(), &usdc())
356            .unwrap();
357        assert_eq!(result.amount, BigUint::from(145_000_000_000u64));
358    }
359
360    #[test]
361    fn get_amount_out_on_glitched_ladder_is_rejected() {
362        // A ladder that is not monotonically increasing in amount_out (a stream glitch): the
363        // data is unreliable, so a quote landing in the glitched segment is refused instead of
364        // interpolated (or underflowing).
365        let state = PriceLevelStreamState::new(
366            wbtc().address,
367            usdc().address,
368            vec![quote(100, 200), quote(200, 150)],
369            vec![],
370            BigUint::ZERO,
371        );
372        let result = state.get_amount_out(BigUint::from(150u64), &wbtc(), &usdc());
373        assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
374
375        // Hitting a level exactly returns that genuine sample even on a glitched ladder.
376        let result = state
377            .get_amount_out(BigUint::from(100u64), &wbtc(), &usdc())
378            .unwrap();
379        assert_eq!(result.amount, BigUint::from(200u64));
380    }
381
382    #[test]
383    fn get_amount_out_below_smallest_level_is_rejected() {
384        // The ladder holds no information below its smallest quote, and there is no partial
385        // result to offer.
386        let result = state().get_amount_out(BigUint::from(50_000_000u64), &wbtc(), &usdc());
387        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
388    }
389
390    #[test]
391    fn get_amount_out_reverse_direction() {
392        let result = state()
393            .get_amount_out(BigUint::from(100_000_000_000u64), &usdc(), &wbtc())
394            .unwrap();
395        assert_eq!(result.amount, BigUint::from(99_000_000u64));
396    }
397
398    #[test]
399    fn get_amount_out_beyond_largest_level_is_partial() {
400        let result = state().get_amount_out(BigUint::from(300_000_000u64), &wbtc(), &usdc());
401        match result {
402            Err(SimulationError::InvalidInput(_, Some(partial))) => {
403                assert_eq!(partial.amount, BigUint::from(190_000_000_000u64));
404            }
405            other => panic!("expected partial InvalidInput, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn get_amount_out_consumes_both_ladders() {
411        let result = state()
412            .get_amount_out(BigUint::from(100_000_000u64), &wbtc(), &usdc())
413            .unwrap();
414        let new_state = result
415            .new_state
416            .as_any()
417            .downcast_ref::<PriceLevelStreamState>()
418            .expect("price level state");
419        assert!(new_state.quotes_0_to_1.is_empty());
420        assert!(new_state.quotes_1_to_0.is_empty());
421    }
422
423    #[test]
424    fn get_amount_out_rejects_unknown_tokens() {
425        let result = state().get_amount_out(BigUint::from(1u64), &weth(), &usdc());
426        assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
427    }
428
429    #[test]
430    fn get_amount_out_without_liquidity() {
431        let state = PriceLevelStreamState::new(
432            wbtc().address,
433            usdc().address,
434            vec![],
435            vec![],
436            BigUint::ZERO,
437        );
438        let result = state.get_amount_out(BigUint::from(1u64), &wbtc(), &usdc());
439        assert!(matches!(result, Err(SimulationError::RecoverableError(_))));
440    }
441
442    #[test]
443    fn spot_price_uses_smallest_quote() {
444        // 1 WBTC (1e8) -> 100_000 USDC (1e11 at 6 decimals).
445        let price = state()
446            .spot_price(&wbtc(), &usdc())
447            .unwrap();
448        assert!((price - 100_000.0).abs() < 1e-9);
449
450        let inverse = state()
451            .spot_price(&usdc(), &wbtc())
452            .unwrap();
453        // 100k USDC (1e11 at 6 decimals) -> 0.99 WBTC: 0.99 / 100_000 = 9.9e-6.
454        assert!((inverse - 9.9e-6).abs() < 1e-15);
455    }
456
457    #[test]
458    fn spot_price_skips_zero_amount_out_quotes() {
459        // A dust level rounding to zero output must not produce a spot price of 0 — consumers
460        // computing 1/spot_price would divide by zero.
461        let state = PriceLevelStreamState::new(
462            wbtc().address,
463            usdc().address,
464            vec![quote(1, 0), quote(100_000_000, 100_000_000_000)],
465            vec![],
466            BigUint::ZERO,
467        );
468        let price = state
469            .spot_price(&wbtc(), &usdc())
470            .unwrap();
471        assert!((price - 100_000.0).abs() < 1e-9);
472    }
473
474    #[test]
475    fn get_limits_returns_largest_quote() {
476        let (max_in, max_out) = state()
477            .get_limits(wbtc().address, usdc().address)
478            .unwrap();
479        assert_eq!(max_in, BigUint::from(200_000_000u64));
480        assert_eq!(max_out, BigUint::from(190_000_000_000u64));
481    }
482
483    #[test]
484    fn get_limits_without_liquidity() {
485        let state = PriceLevelStreamState::new(
486            wbtc().address,
487            usdc().address,
488            vec![],
489            vec![],
490            BigUint::ZERO,
491        );
492        let (max_in, max_out) = state
493            .get_limits(wbtc().address, usdc().address)
494            .unwrap();
495        assert_eq!(max_in, BigUint::ZERO);
496        assert_eq!(max_out, BigUint::ZERO);
497    }
498
499    #[test]
500    fn eq_compares_quotes() {
501        let a = state();
502        let mut b = state();
503        assert!(a.eq(&b as &dyn ProtocolSim));
504        b.quotes_0_to_1[0].amount_out += 1u32;
505        assert!(!a.eq(&b as &dyn ProtocolSim));
506    }
507}