Skip to main content

tycho_simulation/rfq/
models.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use tycho_client::feed::{BlockHeader, HeaderLike};
5use tycho_common::Bytes;
6
7#[derive(Clone, Default, Debug)]
8pub struct TimestampHeader {
9    pub timestamp: u64,
10}
11
12impl HeaderLike for TimestampHeader {
13    fn block(self) -> Option<BlockHeader> {
14        None
15    }
16
17    fn block_number_or_timestamp(self) -> u64 {
18        self.timestamp
19    }
20}
21
22/// One level of a market maker's book: `quantity` base tokens at `price` quote tokens each,
23/// both in whole units.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct PriceLevel {
26    #[serde(
27        rename = "q",
28        deserialize_with = "deserialize_string_to_f64",
29        serialize_with = "serialize_f64_to_string"
30    )]
31    pub quantity: f64,
32    #[serde(
33        rename = "p",
34        deserialize_with = "deserialize_string_to_f64",
35        serialize_with = "serialize_f64_to_string"
36    )]
37    pub price: f64,
38}
39
40fn deserialize_string_to_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
41where
42    D: serde::Deserializer<'de>,
43{
44    let s = String::deserialize(deserializer)?;
45    s.parse()
46        .map_err(serde::de::Error::custom)
47}
48
49fn serialize_f64_to_string<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
50where
51    S: serde::Serializer,
52{
53    serializer.serialize_str(&value.to_string())
54}
55
56/// Consumes `levels` in order until `amount_in` is filled or they run out. Returns the amount
57/// out and the amount in that no level filled.
58pub fn fill_levels(levels: &[PriceLevel], amount_in: f64) -> (f64, f64) {
59    let mut remaining_amount_in = amount_in;
60    let mut amount_out = 0.0;
61    for level in levels {
62        if remaining_amount_in <= 0.0 {
63            break;
64        }
65        let filled = remaining_amount_in.min(level.quantity);
66        amount_out += filled * level.price;
67        remaining_amount_in -= filled;
68    }
69    (amount_out, remaining_amount_in)
70}
71
72/// The components an RFQ client streams, and so the state type its stream decodes to.
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ComponentLayout {
76    /// One component per token pair, decoded by the venue's per-pair state, such as
77    /// `HashflowState`. A swap leaves the state
78    /// unchanged, so one route may quote the pair again.
79    #[default]
80    PerPair,
81    /// One component for all token pairs, decoded by the venue's all-pairs state, such as
82    /// `HashflowAllPairsState`. A swap marks what it used
83    /// in the state it returns, as the venue's [`QuoteRule`] says.
84    AllPairs,
85}
86
87/// How often one route may take quotes from an RFQ venue.
88///
89/// A swap records what it used in the state it returns, so the next swap on that state sees it.
90/// The rule travels with the venue's component as the `quote_rule` static attribute.
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum QuoteRule {
94    /// Every market maker quotes once per route. Only for a venue that names its makers and lets
95    /// the taker pick one.
96    OncePerMaker,
97    /// The venue quotes once per route.
98    OncePerVenue,
99}
100
101impl QuoteRule {
102    pub const ATTRIBUTE: &'static str = "quote_rule";
103    const ALL: [QuoteRule; 2] = [QuoteRule::OncePerMaker, QuoteRule::OncePerVenue];
104
105    /// The rule as its static attribute value.
106    pub fn as_str(self) -> &'static str {
107        match self {
108            QuoteRule::OncePerMaker => "once_per_maker",
109            QuoteRule::OncePerVenue => "once_per_venue",
110        }
111    }
112
113    /// The rule a component's static attributes carry, if any.
114    pub fn from_attributes(
115        attributes: &HashMap<String, Bytes>,
116    ) -> Result<Option<QuoteRule>, String> {
117        let Some(value) = attributes.get(Self::ATTRIBUTE) else {
118            return Ok(None);
119        };
120        Self::ALL
121            .into_iter()
122            .find(|rule| rule.as_str().as_bytes() == value.as_ref())
123            .map(Some)
124            .ok_or_else(|| {
125                format!("Unknown quote_rule attribute: {}", String::from_utf8_lossy(value))
126            })
127    }
128
129    /// Whether `market_maker` may still quote after the makers in `used` did.
130    pub fn allows(self, used: &HashSet<String>, market_maker: &str) -> bool {
131        match self {
132            QuoteRule::OncePerMaker => !used.contains(market_maker),
133            QuoteRule::OncePerVenue => used.is_empty(),
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn fill_levels_stops_when_levels_run_out() {
144        let levels = vec![
145            PriceLevel { quantity: 1.0, price: 3000.0 },
146            PriceLevel { quantity: 2.0, price: 2999.0 },
147        ];
148        assert_eq!(fill_levels(&levels, 1.0), (3000.0, 0.0));
149        assert_eq!(fill_levels(&levels, 2.0), (5999.0, 0.0));
150        assert_eq!(fill_levels(&levels, 5.0), (8998.0, 2.0));
151    }
152
153    #[test]
154    fn quote_rule_attribute_round_trip() {
155        for rule in [QuoteRule::OncePerMaker, QuoteRule::OncePerVenue] {
156            let attributes = HashMap::from([(
157                QuoteRule::ATTRIBUTE.to_string(),
158                rule.as_str().as_bytes().into(),
159            )]);
160            assert_eq!(QuoteRule::from_attributes(&attributes), Ok(Some(rule)));
161        }
162    }
163
164    #[test]
165    fn quote_rule_attribute_absent() {
166        assert_eq!(QuoteRule::from_attributes(&HashMap::new()), Ok(None));
167    }
168
169    #[test]
170    fn quote_rule_attribute_unknown_value() {
171        let attributes =
172            HashMap::from([(QuoteRule::ATTRIBUTE.to_string(), b"twice_per_venue".into())]);
173        let result = QuoteRule::from_attributes(&attributes);
174        assert!(matches!(result, Err(message) if message.contains("twice_per_venue")));
175    }
176}