Skip to main content

tycho_simulation/rfq/protocols/metric/
state.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use async_trait::async_trait;
4use num_bigint::BigUint;
5use num_traits::{FromPrimitive, ToPrimitive, Zero};
6use serde::{Deserialize, Serialize};
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::{protocol::GetAmountOutParams, token::Token},
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        indicatively_priced::{IndicativelyPriced, SignedQuote},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::rfq::protocols::metric::{
19    client::MetricClient,
20    models::{MetricBidAskResponse, MetricDepthBin, MetricMetadata},
21};
22
23#[derive(Clone, Serialize, Deserialize)]
24pub struct MetricState {
25    pub base_token: Token,
26    pub quote_token: Token,
27    pub metadata: MetricMetadata,
28    pub bid_ask: MetricBidAskResponse,
29    pub client: MetricClient,
30}
31
32impl fmt::Debug for MetricState {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_struct("MetricState")
35            .field("base_token", &self.base_token)
36            .field("quote_token", &self.quote_token)
37            .field("pool", &self.metadata.pool_address)
38            .field("latest_block", &self.bid_ask.latest_block)
39            .finish_non_exhaustive()
40    }
41}
42
43impl MetricState {
44    pub fn new(
45        base_token: Token,
46        quote_token: Token,
47        metadata: MetricMetadata,
48        bid_ask: MetricBidAskResponse,
49        client: MetricClient,
50    ) -> Self {
51        Self { base_token, quote_token, metadata, bid_ask, client }
52    }
53
54    fn direction(
55        &self,
56        token_in: &Bytes,
57        token_out: &Bytes,
58    ) -> Result<MetricDirection, SimulationError> {
59        if token_in == &self.base_token.address && token_out == &self.quote_token.address {
60            Ok(MetricDirection::ZeroForOne)
61        } else if token_in == &self.quote_token.address && token_out == &self.base_token.address {
62            Ok(MetricDirection::OneForZero)
63        } else {
64            Err(SimulationError::InvalidInput(
65                format!(
66                    "Invalid token addresses. Got in={token_in}, out={token_out}, expected {} / {}",
67                    self.base_token.address, self.quote_token.address
68                ),
69                None,
70            ))
71        }
72    }
73
74    fn quote_with_depth(
75        &self,
76        direction: MetricDirection,
77        amount_in_human: f64,
78        token_out_decimals: u32,
79        max_output: &BigUint,
80    ) -> Result<Option<DepthQuote>, SimulationError> {
81        let (start_price, bins) = match direction {
82            MetricDirection::ZeroForOne => (self.bid_ask.bid_price()?, &self.bid_ask.depth.bids),
83            MetricDirection::OneForZero => (self.bid_ask.ask_price()?, &self.bid_ask.depth.asks),
84        };
85
86        // Some pools still return an empty depth object. In that case the top-of-book quote is
87        // the best signal we have, so keep the old flat-price path.
88        let Some(depth_max_output) = depth_max_output(bins)? else {
89            return Ok(None);
90        };
91
92        let effective_max_output = depth_max_output.min(max_output.clone());
93        if effective_max_output.is_zero() {
94            return Ok(Some(DepthQuote {
95                amount_out_human: 0.0,
96                max_output: effective_max_output,
97                exhausted: amount_in_human > 0.0,
98            }));
99        }
100
101        let max_output_human =
102            raw_to_human(&effective_max_output, token_out_decimals, "depth max output")?;
103        let depth_fill = depth_output_for_input(
104            direction,
105            bins,
106            start_price,
107            amount_in_human,
108            token_out_decimals,
109            max_output_human,
110        )?;
111
112        Ok(Some(DepthQuote {
113            amount_out_human: depth_fill.output_human,
114            max_output: effective_max_output,
115            exhausted: depth_fill.exhausted,
116        }))
117    }
118}
119
120#[derive(Debug, Clone, Copy)]
121enum MetricDirection {
122    ZeroForOne,
123    OneForZero,
124}
125
126struct DepthQuote {
127    amount_out_human: f64,
128    max_output: BigUint,
129    exhausted: bool,
130}
131
132struct DepthFill {
133    output_human: f64,
134    exhausted: bool,
135}
136
137#[typetag::serde]
138impl ProtocolSim for MetricState {
139    fn fee(&self) -> f64 {
140        0.0
141    }
142
143    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
144        let bid = self.bid_ask.bid_price()?;
145        let ask = self.bid_ask.ask_price()?;
146        let mid = (bid + ask) / 2.0;
147        if base.address == self.base_token.address && quote.address == self.quote_token.address {
148            Ok(mid)
149        } else if base.address == self.quote_token.address &&
150            quote.address == self.base_token.address
151        {
152            Ok(1.0 / mid)
153        } else {
154            Err(SimulationError::InvalidInput(
155                format!(
156                    "Invalid token addresses. Got base={}, quote={}, expected {} / {}",
157                    base.address, quote.address, self.base_token.address, self.quote_token.address
158                ),
159                None,
160            ))
161        }
162    }
163
164    fn get_amount_out(
165        &self,
166        amount_in: BigUint,
167        token_in: &Token,
168        token_out: &Token,
169    ) -> Result<GetAmountOutResult, SimulationError> {
170        let direction = self.direction(&token_in.address, &token_out.address)?;
171        let amount_in_human = amount_in.to_f64().ok_or_else(|| {
172            SimulationError::RecoverableError("Can't convert amount in to f64".into())
173        })? / 10_f64.powi(token_in.decimals as i32);
174
175        let (flat_amount_out_human, max_output) = match direction {
176            MetricDirection::ZeroForOne => {
177                let price = self.bid_ask.bid_price()?;
178                (amount_in_human * price, self.bid_ask.total_token1_available()?)
179            }
180            MetricDirection::OneForZero => {
181                let price = self.bid_ask.ask_price()?;
182                (amount_in_human / price, self.bid_ask.total_token0_available()?)
183            }
184        };
185        // Prefer size-aware depth when Metric exposes it, otherwise use best bid/ask with only
186        // the aggregate inventory cap.
187        let depth_quote =
188            self.quote_with_depth(direction, amount_in_human, token_out.decimals, &max_output)?;
189        let (amount_out_human, effective_max_output, exhausted) = match depth_quote {
190            Some(quote) => (quote.amount_out_human, quote.max_output, quote.exhausted),
191            None => (flat_amount_out_human, max_output.clone(), false),
192        };
193
194        let amount_out =
195            BigUint::from_f64(amount_out_human * 10_f64.powi(token_out.decimals as i32))
196                .ok_or_else(|| {
197                    SimulationError::RecoverableError("Can't convert amount out to BigUint".into())
198                })?;
199        let capped_amount = amount_out
200            .clone()
201            .min(effective_max_output.clone());
202        let res = GetAmountOutResult {
203            amount: capped_amount.clone(),
204            gas: BigUint::from(170_000u64),
205            new_state: self.clone_box(),
206        };
207
208        if exhausted || amount_out > effective_max_output {
209            return Err(SimulationError::InvalidInput(
210                format!(
211                    "Metric pool has not enough liquidity. Requested output {}, available {}",
212                    amount_out, effective_max_output
213                ),
214                Some(res),
215            ));
216        }
217
218        Ok(res)
219    }
220
221    fn get_limits(
222        &self,
223        sell_token: Bytes,
224        buy_token: Bytes,
225    ) -> Result<(BigUint, BigUint), SimulationError> {
226        let direction = self.direction(&sell_token, &buy_token)?;
227        match direction {
228            MetricDirection::ZeroForOne => {
229                let price = self.bid_ask.bid_price()?;
230                let buy_limit = self.bid_ask.total_token1_available()?;
231                let buy_limit_human = buy_limit.to_f64().ok_or_else(|| {
232                    SimulationError::RecoverableError("Can't convert buy limit to f64".into())
233                })? / 10_f64.powi(self.quote_token.decimals as i32);
234                let sell_limit = buy_limit_human / price;
235                let sell_limit =
236                    BigUint::from_f64(sell_limit * 10_f64.powi(self.base_token.decimals as i32))
237                        .ok_or_else(|| {
238                            SimulationError::RecoverableError(
239                                "Can't convert sell limit to BigUint".into(),
240                            )
241                        })?;
242                Ok((sell_limit, buy_limit))
243            }
244            MetricDirection::OneForZero => {
245                let price = self.bid_ask.ask_price()?;
246                let buy_limit = self.bid_ask.total_token0_available()?;
247                let buy_limit_human = buy_limit.to_f64().ok_or_else(|| {
248                    SimulationError::RecoverableError("Can't convert buy limit to f64".into())
249                })? / 10_f64.powi(self.base_token.decimals as i32);
250                let sell_limit = buy_limit_human * price;
251                let sell_limit =
252                    BigUint::from_f64(sell_limit * 10_f64.powi(self.quote_token.decimals as i32))
253                        .ok_or_else(|| {
254                        SimulationError::RecoverableError(
255                            "Can't convert sell limit to BigUint".into(),
256                        )
257                    })?;
258                Ok((sell_limit, buy_limit))
259            }
260        }
261    }
262
263    fn as_indicatively_priced(&self) -> Result<&dyn IndicativelyPriced, SimulationError> {
264        Ok(self)
265    }
266
267    fn delta_transition(
268        &mut self,
269        _delta: ProtocolStateDelta,
270        _tokens: &HashMap<Bytes, Token>,
271        _balances: &Balances,
272    ) -> Result<(), TransitionError> {
273        // RFQ updates arrive as full API snapshots, not block deltas.
274        Err(TransitionError::DecodeError(
275            "Metric RFQ state is snapshot-based and does not support deltas".into(),
276        ))
277    }
278
279    fn clone_box(&self) -> Box<dyn ProtocolSim> {
280        Box::new(self.clone())
281    }
282
283    fn as_any(&self) -> &dyn Any {
284        self
285    }
286
287    fn as_any_mut(&mut self) -> &mut dyn Any {
288        self
289    }
290
291    fn eq(&self, other: &dyn ProtocolSim) -> bool {
292        if let Some(other_state) = other
293            .as_any()
294            .downcast_ref::<MetricState>()
295        {
296            self.base_token == other_state.base_token &&
297                self.quote_token == other_state.quote_token &&
298                self.metadata == other_state.metadata &&
299                self.bid_ask == other_state.bid_ask
300        } else {
301            false
302        }
303    }
304}
305
306fn depth_max_output(bins: &[MetricDepthBin]) -> Result<Option<BigUint>, SimulationError> {
307    bins.last()
308        .map(|bin| {
309            bin.cumulative_volume()
310                .map_err(SimulationError::from)
311        })
312        .transpose()
313}
314
315fn depth_output_for_input(
316    direction: MetricDirection,
317    bins: &[MetricDepthBin],
318    start_price: f64,
319    input_human: f64,
320    output_decimals: u32,
321    max_output_human: f64,
322) -> Result<DepthFill, SimulationError> {
323    if input_human == 0.0 || max_output_human == 0.0 {
324        return Ok(DepthFill { output_human: 0.0, exhausted: input_human > 0.0 });
325    }
326
327    let mut current_price = start_price;
328    let mut previous_volume = 0.0;
329    let mut remaining_input = input_human;
330    let mut output_human = 0.0;
331
332    for bin in bins {
333        let bin_price = bin.price()?;
334        // Metric reports cumulative depth in output-token units for the side being walked.
335        // Adjacent differences give the volume available in each linear price segment.
336        let cumulative =
337            raw_to_human(&bin.cumulative_volume()?, output_decimals, "depth cumulative volume")?;
338        let volume_in_bin = cumulative - previous_volume;
339        previous_volume = cumulative;
340
341        if volume_in_bin <= 0.0 {
342            current_price = bin_price;
343            continue;
344        }
345
346        let output_capacity = max_output_human - output_human;
347        if output_capacity <= 0.0 {
348            break;
349        }
350        let fillable_volume = volume_in_bin.min(output_capacity);
351        // If the aggregate liquidity cap cuts this bin short, the segment can end before bin_price.
352        let segment_exit_price =
353            current_price + (bin_price - current_price) * fillable_volume / volume_in_bin;
354        // First price this whole segment. If the remaining input can pay that cost, the quote
355        // consumes fillable_volume completely and then continues into the next depth bin.
356        let full_segment_input = depth_segment_input_required(
357            direction,
358            fillable_volume,
359            current_price,
360            segment_exit_price,
361        )?;
362
363        if remaining_input >= full_segment_input {
364            output_human += fillable_volume;
365            remaining_input -= full_segment_input;
366            current_price = segment_exit_price;
367            continue;
368        }
369
370        // The remaining input is not enough for the whole segment, so the trade stops between
371        // current_price and segment_exit_price. Invert this segment's price formula to compute
372        // the partial output bought by remaining_input.
373        output_human += depth_segment_output_for_input(
374            direction,
375            remaining_input,
376            fillable_volume,
377            current_price,
378            segment_exit_price,
379        )?;
380        remaining_input = 0.0;
381        break;
382    }
383
384    if remaining_input > 1e-12 && output_human < max_output_human - 1e-12 {
385        return Err(SimulationError::RecoverableError(
386            "Metric depth has not enough cumulative volume".into(),
387        ));
388    }
389
390    Ok(DepthFill {
391        output_human: output_human.min(max_output_human),
392        exhausted: remaining_input > 1e-12,
393    })
394}
395
396fn depth_segment_input_required(
397    direction: MetricDirection,
398    output_human: f64,
399    entry_price: f64,
400    exit_price: f64,
401) -> Result<f64, SimulationError> {
402    let average_price = depth_segment_average_price(entry_price, exit_price)?;
403    match direction {
404        MetricDirection::ZeroForOne => Ok(output_human / average_price),
405        MetricDirection::OneForZero => Ok(output_human * average_price),
406    }
407}
408
409fn depth_segment_output_for_input(
410    direction: MetricDirection,
411    input_human: f64,
412    segment_output_human: f64,
413    entry_price: f64,
414    exit_price: f64,
415) -> Result<f64, SimulationError> {
416    if segment_output_human <= 0.0 || entry_price <= 0.0 {
417        return Err(SimulationError::RecoverableError(
418            "Metric depth has invalid price curve".into(),
419        ));
420    }
421
422    // Variables for this segment:
423    //   V  = segment_output_human, the max output available in this segment.
424    //   p0 = entry_price, the price at x = 0.
425    //   p1 = exit_price, the price at x = V.
426    //   x  = output filled inside this segment, where 0 <= x <= V.
427    //   I  = input_human, the input available for this partial segment.
428    //
429    // Metric linearly interpolates the price reached after filling x output:
430    //   exit_price_at_x = p0 + (p1 - p0) * x / V
431    //
432    // Metric prices the partial fill by averaging the start price and that exit price:
433    //   avg_price(x) = (p0 + exit_price_at_x) / 2
434    //
435    // Substitute exit_price_at_x:
436    //   avg_price(x) = (p0 + p0 + (p1 - p0) * x / V) / 2
437    //                = p0 + (p1 - p0) * x / (2 * V)
438    //
439    // Store the x coefficient as average_slope:
440    //   average_slope = (p1 - p0) / (2 * V)
441    //   avg_price(x) = p0 + average_slope * x
442    let average_slope = (exit_price - entry_price) / (2.0 * segment_output_human);
443    let output = match direction {
444        MetricDirection::ZeroForOne => {
445            // ZeroForOne sells base for quote, so price is quote/base and:
446            //
447            //   I = x / avg_price(x)
448            //
449            // Substitute avg_price(x):
450            //   I = x / (p0 + average_slope * x)
451            //
452            // Solve for x:
453            //   x = I * p0 / (1 - I * average_slope)
454            let denominator = 1.0 - input_human * average_slope;
455            if denominator <= 0.0 {
456                return Err(SimulationError::RecoverableError(
457                    "Metric depth has invalid price curve".into(),
458                ));
459            }
460            input_human * entry_price / denominator
461        }
462        MetricDirection::OneForZero => {
463            // OneForZero sells quote for base, so price is quote/base and:
464            //
465            //   I = x * avg_price(x)
466            //
467            // Substitute avg_price(x):
468            //   I = x * (p0 + average_slope * x)
469            //   I = p0 * x + average_slope * x^2
470            //
471            // Rearrange into the standard quadratic form a*x^2 + b*x + c = 0:
472            //   average_slope * x^2 + p0 * x - I = 0
473            //
474            // Here:
475            //   a = average_slope
476            //   b = p0
477            //   c = -I
478            //
479            // The quadratic formula uses sqrt(b^2 - 4*a*c):
480            //   b^2 - 4*a*c = p0^2 + 4 * average_slope * I
481            //
482            // The valid solution is the root inside [0, segment_output_human].
483            if average_slope.abs() < 1e-18 {
484                input_human / entry_price
485            } else {
486                let discriminant =
487                    entry_price.mul_add(entry_price, 4.0 * average_slope * input_human);
488                if discriminant < 0.0 {
489                    return Err(SimulationError::RecoverableError(
490                        "Metric depth has invalid price curve".into(),
491                    ));
492                }
493                let root_a = (-entry_price + discriminant.sqrt()) / (2.0 * average_slope);
494                let root_b = (-entry_price - discriminant.sqrt()) / (2.0 * average_slope);
495                [root_a, root_b]
496                    .into_iter()
497                    .find(|root| {
498                        root.is_finite() && *root >= -1e-12 && *root <= segment_output_human + 1e-12
499                    })
500                    .ok_or_else(|| {
501                        SimulationError::RecoverableError(
502                            "Metric depth has invalid price curve".into(),
503                        )
504                    })?
505            }
506        }
507    };
508
509    Ok(output.clamp(0.0, segment_output_human))
510}
511
512fn depth_segment_average_price(entry_price: f64, exit_price: f64) -> Result<f64, SimulationError> {
513    let average_price = (entry_price + exit_price) / 2.0;
514    if average_price <= 0.0 {
515        return Err(SimulationError::RecoverableError(
516            "Metric depth has non-positive average price".into(),
517        ));
518    }
519    Ok(average_price)
520}
521
522fn raw_to_human(amount: &BigUint, decimals: u32, field: &str) -> Result<f64, SimulationError> {
523    let amount = amount.to_f64().ok_or_else(|| {
524        SimulationError::RecoverableError(format!("Can't convert {field} to f64"))
525    })?;
526    Ok(amount / 10_f64.powi(decimals as i32))
527}
528
529#[async_trait]
530impl IndicativelyPriced for MetricState {
531    async fn request_signed_quote(
532        &self,
533        params: GetAmountOutParams,
534    ) -> Result<SignedQuote, SimulationError> {
535        let direction = self.direction(&params.token_in, &params.token_out)?;
536        let (token_in, token_out) = match direction {
537            MetricDirection::ZeroForOne => (&self.base_token, &self.quote_token),
538            MetricDirection::OneForZero => (&self.quote_token, &self.base_token),
539        };
540        let amount_out = self
541            .get_amount_out(params.amount_in.clone(), token_in, token_out)?
542            .amount;
543
544        // Metric's swap path is independent from the quote API. Execution uses this hook to fetch
545        // signed oracle-update args that can be submitted before the pool swap.
546        Ok(self
547            .client
548            .request_oracle_update_for_pool(&self.metadata, &params, amount_out)
549            .await?)
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use std::{collections::HashSet, str::FromStr};
556
557    use tokio::time::Duration;
558    use tycho_common::models::Chain;
559
560    use super::*;
561    use crate::rfq::protocols::metric::{client::MetricClient, models::MetricDepth};
562
563    fn weth() -> Token {
564        Token::new(
565            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
566            "WETH",
567            18,
568            0,
569            &[Some(2300)],
570            Chain::Ethereum,
571            100,
572        )
573    }
574
575    fn usdc() -> Token {
576        Token::new(
577            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
578            "USDC",
579            6,
580            0,
581            &[Some(1)],
582            Chain::Ethereum,
583            100,
584        )
585    }
586
587    fn state() -> MetricState {
588        let weth = weth();
589        let usdc = usdc();
590        let metadata = MetricMetadata {
591            pool_address: Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap(),
592            token0: weth.address.clone(),
593            token1: usdc.address.clone(),
594        };
595        let bid_ask = MetricBidAskResponse {
596            // 3000 * 2^64
597            bid_adj: "55340232221128654848000".to_string(),
598            // 3010 * 2^64
599            ask_adj: "55524699661865750400000".to_string(),
600            quote_available: true,
601            total_token0_available: "10000000000000000000".to_string(),
602            total_token1_available: "30000000000".to_string(),
603            latest_block: 100,
604            depth: MetricDepth::default(),
605        };
606        let client = MetricClient::new(
607            Chain::Ethereum,
608            HashSet::new(),
609            0.0,
610            HashSet::new(),
611            "http://localhost:8080".to_string(),
612            None,
613            Duration::from_secs(1),
614            Duration::from_secs(1),
615        )
616        .unwrap();
617        MetricState::new(weth, usdc, metadata, bid_ask, client)
618    }
619
620    #[test]
621    fn test_get_amount_out_zero_for_one() {
622        let state = state();
623        let result = state
624            .get_amount_out(
625                BigUint::from(1_000_000_000_000_000_000u128),
626                &state.base_token,
627                &state.quote_token,
628            )
629            .unwrap();
630
631        assert_eq!(result.amount, BigUint::from(3_000_000_000u64));
632    }
633
634    #[test]
635    fn test_get_amount_out_one_for_zero() {
636        let state = state();
637        let result = state
638            .get_amount_out(BigUint::from(3_010_000_000u64), &state.quote_token, &state.base_token)
639            .unwrap();
640
641        assert_eq!(result.amount, BigUint::from(1_000_000_000_000_000_000u128));
642    }
643
644    #[test]
645    fn test_get_amount_out_caps_to_available_liquidity() {
646        let mut state = state();
647        state.bid_ask.total_token1_available = "1500000000".to_string();
648        let err = state
649            .get_amount_out(
650                BigUint::from(1_000_000_000_000_000_000u128),
651                &state.base_token,
652                &state.quote_token,
653            )
654            .unwrap_err();
655
656        assert!(matches!(err, SimulationError::InvalidInput(_, Some(_))));
657    }
658
659    #[test]
660    fn test_get_amount_out_walks_bid_depth() {
661        let mut state = state();
662        state.bid_ask.depth.bids = vec![MetricDepthBin {
663            bin_idx: 0,
664            // 2900 * 2^64
665            price: "53495557813757699686400".to_string(),
666            cumulative_volume: "3000000000".to_string(),
667        }];
668
669        let result = state
670            .get_amount_out(
671                BigUint::from(1_000_000_000_000_000_000u128),
672                &state.base_token,
673                &state.quote_token,
674            )
675            .unwrap();
676
677        assert!(result.amount < BigUint::from(3_000_000_000u64));
678        assert!(result.amount > BigUint::from(2_950_000_000u64));
679    }
680
681    #[test]
682    fn test_get_amount_out_walks_ask_depth() {
683        let mut state = state();
684        state.bid_ask.depth.asks = vec![MetricDepthBin {
685            bin_idx: 0,
686            // 3100 * 2^64
687            price: "57184906628499610009600".to_string(),
688            cumulative_volume: "1000000000000000000".to_string(),
689        }];
690
691        let result = state
692            .get_amount_out(BigUint::from(3_000_000_000u64), &state.quote_token, &state.base_token)
693            .unwrap();
694
695        assert!(result.amount < BigUint::from(1_000_000_000_000_000_000u128));
696        assert!(result.amount > BigUint::from(980_000_000_000_000_000u128));
697    }
698
699    #[test]
700    fn test_depth_output_for_input_partially_fills_bid_bin() {
701        let bins = vec![MetricDepthBin {
702            bin_idx: 0,
703            // 2900 * 2^64
704            price: "53495557813757699686400".to_string(),
705            cumulative_volume: "3000000000".to_string(),
706        }];
707
708        let fill =
709            depth_output_for_input(MetricDirection::ZeroForOne, &bins, 3000.0, 1.0, 6, 3000.0)
710                .unwrap();
711
712        assert!((fill.output_human - 2950.8196721311474).abs() < 1e-9);
713        assert!(!fill.exhausted);
714    }
715
716    #[test]
717    fn test_depth_output_for_input_partially_fills_ask_bin() {
718        let bins = vec![MetricDepthBin {
719            bin_idx: 0,
720            // 3100 * 2^64
721            price: "57184906628499610009600".to_string(),
722            cumulative_volume: "1000000000000000000".to_string(),
723        }];
724
725        let fill =
726            depth_output_for_input(MetricDirection::OneForZero, &bins, 3010.0, 3000.0, 18, 1.0)
727                .unwrap();
728
729        assert!((fill.output_human - 0.9822534928279816).abs() < 1e-12);
730        assert!(!fill.exhausted);
731    }
732
733    #[test]
734    fn test_depth_output_for_input_exhausts_available_depth() {
735        let bins = vec![MetricDepthBin {
736            bin_idx: 0,
737            // 2900 * 2^64
738            price: "53495557813757699686400".to_string(),
739            cumulative_volume: "3000000000".to_string(),
740        }];
741
742        let fill =
743            depth_output_for_input(MetricDirection::ZeroForOne, &bins, 3000.0, 2.0, 6, 3000.0)
744                .unwrap();
745
746        assert_eq!(fill.output_human, 3000.0);
747        assert!(fill.exhausted);
748    }
749
750    #[tokio::test]
751    #[ignore = "hits Metric's public API"]
752    async fn test_live_metric_api_state_get_amount_out_and_oracle_update() {
753        let weth = weth();
754        let usdc = usdc();
755        let base_url = std::env::var("METRIC_API_URL")
756            .unwrap_or_else(|_| "http://54.199.103.16:8080".to_string())
757            .trim_end_matches('/')
758            .to_string();
759        let client = MetricClient::new(
760            Chain::Ethereum,
761            HashSet::from([weth.address.clone(), usdc.address.clone()]),
762            0.0,
763            HashSet::new(),
764            base_url.clone(),
765            None,
766            Duration::from_secs(1),
767            Duration::from_secs(5),
768        )
769        .unwrap();
770
771        let http_client = reqwest::Client::new();
772        let metadata: Vec<MetricMetadata> = http_client
773            .get(format!("{base_url}/ethereum/metadata"))
774            .header("accept", "application/json")
775            .send()
776            .await
777            .unwrap()
778            .json()
779            .await
780            .unwrap();
781
782        let mut selected = None;
783        for pool in metadata
784            .into_iter()
785            .filter(|pool| pool.token0 == weth.address && pool.token1 == usdc.address)
786        {
787            let bid_ask: MetricBidAskResponse = http_client
788                .get(format!("{base_url}/ethereum/{}/bid_ask", pool.pool_address))
789                .header("accept", "application/json")
790                .send()
791                .await
792                .unwrap()
793                .json()
794                .await
795                .unwrap();
796            let has_enough_quote_liquidity = bid_ask
797                .total_token1_available()
798                .map(|available| available > BigUint::from(10u8))
799                .unwrap_or(false);
800            if bid_ask.quote_available && has_enough_quote_liquidity {
801                selected = Some((pool, bid_ask));
802                break;
803            }
804        }
805
806        let Some((metadata, bid_ask)) = selected else {
807            eprintln!("Metric live API returned no liquid Ethereum WETH/USDC pool; skipping");
808            return;
809        };
810
811        let state = MetricState::new(weth, usdc, metadata, bid_ask, client);
812        assert!(state.bid_ask.quote_available);
813
814        let amount_in = BigUint::from(1_000_000_000u64);
815        let indicative_quote = state
816            .get_amount_out(amount_in.clone(), &state.base_token, &state.quote_token)
817            .unwrap();
818        let trader = Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap();
819        let signed_quote = state
820            .request_signed_quote(GetAmountOutParams {
821                amount_in,
822                token_in: state.base_token.address.clone(),
823                token_out: state.quote_token.address.clone(),
824                sender: trader.clone(),
825                receiver: trader,
826            })
827            .await
828            .unwrap();
829
830        assert!(indicative_quote.amount > BigUint::from(0u8));
831        assert!(signed_quote.amount_out > BigUint::from(0u8));
832        assert_eq!(signed_quote.base_token, state.base_token.address);
833        assert_eq!(signed_quote.quote_token, state.quote_token.address);
834        assert!(!signed_quote.quote_attributes["oracle_update_0_args"].is_empty());
835    }
836}