levana_perpswap_cosmos/
market_type.rs

1//! Data types for representing the assets covered by a market.
2use cosmwasm_std::{StdError, StdResult};
3use cw_storage_plus::{Key, KeyDeserialize, Prefixer, PrimaryKey};
4use schemars::{
5    schema::{InstanceType, SchemaObject},
6    JsonSchema,
7};
8use serde::de::Visitor;
9
10use crate::prelude::*;
11
12/// Whether the collateral asset is the same as the quote or base asset.
13#[cw_serde]
14#[derive(Eq, Hash, Copy)]
15pub enum MarketType {
16    /// A market where the collateral is the quote asset
17    CollateralIsQuote,
18    /// A market where the collateral is the base asset
19    CollateralIsBase,
20}
21
22/// An identifier for a market.
23#[derive(Clone)]
24pub struct MarketId {
25    base: String,
26    quote: String,
27    market_type: MarketType,
28    encoded: String,
29}
30
31impl std::hash::Hash for MarketId {
32    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
33        self.encoded.hash(state);
34    }
35}
36
37impl PartialEq for MarketId {
38    fn eq(&self, other: &Self) -> bool {
39        self.encoded == other.encoded
40    }
41}
42
43impl Eq for MarketId {}
44
45#[allow(clippy::non_canonical_partial_ord_impl)]
46impl PartialOrd for MarketId {
47    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
48        self.encoded.partial_cmp(&other.encoded)
49    }
50}
51
52impl Ord for MarketId {
53    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
54        self.encoded.cmp(&other.encoded)
55    }
56}
57
58impl std::fmt::Debug for MarketId {
59    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
60        std::fmt::Debug::fmt(&self.encoded, f)
61    }
62}
63
64/// We hardcode a list of assets that are treated as fiat and therefore, when
65/// used as quote, are by default assumed to not be collateral.
66fn is_fiat(s: &str) -> bool {
67    // can be expanded in the future
68    s == "USD" || s == "EUR"
69}
70
71fn make_encoded(base: &str, quote: &str, market_type: MarketType) -> String {
72    let (base_plus, quote_plus) = match (market_type, is_fiat(quote)) {
73        // ATOM_USD but USD is quote and fiat, add the plus to override fiat default
74        (MarketType::CollateralIsQuote, true) => ("", "+"),
75        // ATOM_USDC and USDC is the quote, therefore default will be USDC as collateral, no plus
76        (MarketType::CollateralIsQuote, false) => ("", ""),
77        // ATOM_USD and ATOM should be the collateral, that's assumed, no plus needed
78        (MarketType::CollateralIsBase, true) => ("", ""),
79        // ATOM_USDC and ATOM should be the collateral, need to override with a plus
80        (MarketType::CollateralIsBase, false) => ("+", ""),
81    };
82    format!("{base}{base_plus}_{quote}{quote_plus}")
83}
84
85impl MarketId {
86    /// Construct a new [MarketId].
87    pub fn new(base: impl Into<String>, quote: impl Into<String>, market_type: MarketType) -> Self {
88        let base = base.into();
89        let quote = quote.into();
90        let encoded = make_encoded(&base, &quote, market_type);
91        MarketId {
92            base,
93            quote,
94            market_type,
95            encoded,
96        }
97    }
98
99    /// Is the notional asset USD?
100    ///
101    /// This is used to bypass some currency conversions when they aren't necessary.
102    pub fn is_notional_usd(&self) -> bool {
103        self.get_notional() == "USD"
104    }
105
106    /// Get the string representation of the market.
107    pub fn as_str(&self) -> &str {
108        &self.encoded
109    }
110
111    fn parse(s: &str) -> Option<Self> {
112        let (base, quote) = s.split_once('_')?;
113        let (base, base_is_collateral) = match base.strip_suffix('+') {
114            Some(base) => {
115                if is_fiat(quote) {
116                    return None;
117                } else {
118                    (base, true)
119                }
120            }
121            None => (base, false),
122        };
123        let (quote, quote_is_collateral) = match quote.strip_suffix('+') {
124            Some(quote) => {
125                if is_fiat(quote) {
126                    (quote, true)
127                } else {
128                    return None;
129                }
130            }
131            None => (quote, false),
132        };
133        let market_type = match (base_is_collateral, quote_is_collateral) {
134            (true, true) => return None,
135            (true, false) => MarketType::CollateralIsBase,
136            (false, true) => MarketType::CollateralIsQuote,
137            (false, false) => {
138                if is_fiat(quote) {
139                    MarketType::CollateralIsBase
140                } else {
141                    MarketType::CollateralIsQuote
142                }
143            }
144        };
145
146        assert_eq!(make_encoded(base, quote, market_type), s);
147        Some(MarketId {
148            base: base.to_owned(),
149            quote: quote.to_owned(),
150            market_type,
151            encoded: s.to_owned(),
152        })
153    }
154
155    /// Get the notional currency.
156    pub fn get_notional(&self) -> &str {
157        match self.market_type {
158            MarketType::CollateralIsQuote => &self.base,
159            MarketType::CollateralIsBase => &self.quote,
160        }
161    }
162
163    /// Get the collateral currency
164    pub fn get_collateral(&self) -> &str {
165        match self.market_type {
166            MarketType::CollateralIsQuote => &self.quote,
167            MarketType::CollateralIsBase => &self.base,
168        }
169    }
170
171    /// Get the base currency
172    pub fn get_base(&self) -> &str {
173        &self.base
174    }
175
176    /// Get the quote currency
177    pub fn get_quote(&self) -> &str {
178        &self.quote
179    }
180
181    /// Determine the market type
182    pub fn get_market_type(&self) -> MarketType {
183        self.market_type
184    }
185}
186
187impl Display for MarketId {
188    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
189        f.write_str(&self.encoded)
190    }
191}
192
193impl FromStr for MarketId {
194    type Err = StdError;
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        MarketId::parse(s)
198            .ok_or_else(|| StdError::parse_err("MarketId", format!("Invalid market ID: {s}")))
199    }
200}
201
202impl serde::Serialize for MarketId {
203    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
204    where
205        S: serde::Serializer,
206    {
207        serializer.serialize_str(&self.encoded)
208    }
209}
210
211impl<'de> serde::Deserialize<'de> for MarketId {
212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213    where
214        D: serde::Deserializer<'de>,
215    {
216        deserializer.deserialize_str(MarketIdVisitor)
217    }
218}
219
220struct MarketIdVisitor;
221
222impl<'de> Visitor<'de> for MarketIdVisitor {
223    type Value = MarketId;
224
225    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
226        formatter.write_str("MarketId")
227    }
228
229    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
230    where
231        E: serde::de::Error,
232    {
233        MarketId::parse(v).ok_or_else(|| E::custom(format!("Invalid market ID: {v}")))
234    }
235}
236
237impl JsonSchema for MarketId {
238    fn schema_name() -> String {
239        "MarketId".to_owned()
240    }
241
242    fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
243        SchemaObject {
244            instance_type: Some(InstanceType::String.into()),
245            format: Some("market-id".to_owned()),
246            ..Default::default()
247        }
248        .into()
249    }
250}
251
252impl<'a> PrimaryKey<'a> for MarketId {
253    type Prefix = ();
254    type SubPrefix = ();
255    type Suffix = Self;
256    type SuperSuffix = Self;
257
258    fn key(&self) -> Vec<Key> {
259        let key = Key::Ref(self.encoded.as_bytes());
260
261        vec![key]
262    }
263}
264
265impl<'a> Prefixer<'a> for MarketId {
266    fn prefix(&self) -> Vec<Key> {
267        let key = Key::Ref(self.encoded.as_bytes());
268        vec![key]
269    }
270}
271
272impl KeyDeserialize for MarketId {
273    type Output = MarketId;
274
275    const KEY_ELEMS: u16 = 1;
276
277    fn from_vec(value: Vec<u8>) -> StdResult<Self::Output> {
278        std::str::from_utf8(&value)
279            .map_err(StdError::invalid_utf8)
280            .and_then(|s| s.parse())
281    }
282}
283
284impl KeyDeserialize for &'_ MarketId {
285    type Output = MarketId;
286
287    const KEY_ELEMS: u16 = 1;
288
289    fn from_vec(value: Vec<u8>) -> StdResult<Self::Output> {
290        std::str::from_utf8(&value)
291            .map_err(StdError::invalid_utf8)
292            .and_then(|s| s.parse())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn round_trip_usd_as_collateral() {
302        let orig = MarketId::new("BTC", "USD", MarketType::CollateralIsQuote);
303        assert_eq!(orig.as_str(), "BTC_USD+");
304        let parsed: MarketId = "BTC_USD+".parse().unwrap();
305        assert_eq!(orig, parsed);
306
307        assert_eq!(orig.get_base(), "BTC");
308        assert_eq!(orig.get_quote(), "USD");
309        assert_eq!(orig.get_notional(), "BTC");
310        assert_eq!(orig.get_collateral(), "USD");
311    }
312
313    #[test]
314    fn round_trip_usd_as_notional() {
315        let orig = MarketId::new("BTC", "USD", MarketType::CollateralIsBase);
316        assert_eq!(orig.as_str(), "BTC_USD");
317        let parsed: MarketId = "BTC_USD".parse().unwrap();
318        assert_eq!(orig, parsed);
319
320        assert_eq!(orig.get_base(), "BTC");
321        assert_eq!(orig.get_quote(), "USD");
322        assert_eq!(orig.get_notional(), "USD");
323        assert_eq!(orig.get_collateral(), "BTC");
324    }
325
326    #[test]
327    fn round_trip_usdc_as_collateral() {
328        let orig = MarketId::new("BTC", "USDC", MarketType::CollateralIsQuote);
329        assert_eq!(orig.as_str(), "BTC_USDC");
330        let parsed: MarketId = "BTC_USDC".parse().unwrap();
331        assert_eq!(orig, parsed);
332
333        assert_eq!(orig.get_base(), "BTC");
334        assert_eq!(orig.get_quote(), "USDC");
335        assert_eq!(orig.get_notional(), "BTC");
336        assert_eq!(orig.get_collateral(), "USDC");
337    }
338
339    #[test]
340    fn round_trip_usdc_as_notional() {
341        let orig = MarketId::new("BTC", "USDC", MarketType::CollateralIsBase);
342        assert_eq!(orig.as_str(), "BTC+_USDC");
343        let parsed: MarketId = "BTC+_USDC".parse().unwrap();
344        assert_eq!(orig, parsed);
345
346        assert_eq!(orig.get_base(), "BTC");
347        assert_eq!(orig.get_quote(), "USDC");
348        assert_eq!(orig.get_notional(), "USDC");
349        assert_eq!(orig.get_collateral(), "BTC");
350    }
351
352    #[test]
353    fn no_unnecessary_plus() {
354        MarketId::from_str("BTC+_USD").unwrap_err();
355        MarketId::from_str("BTC_USDC+").unwrap_err();
356        assert_eq!(
357            MarketId::from_str("BTC_USD").unwrap(),
358            MarketId::new("BTC", "USD", MarketType::CollateralIsBase)
359        );
360        assert_eq!(
361            MarketId::from_str("BTC_USD+").unwrap(),
362            MarketId::new("BTC", "USD", MarketType::CollateralIsQuote)
363        );
364        assert_eq!(
365            MarketId::from_str("BTC_USDC").unwrap(),
366            MarketId::new("BTC", "USDC", MarketType::CollateralIsQuote)
367        );
368        assert_eq!(
369            MarketId::from_str("BTC+_USDC").unwrap(),
370            MarketId::new("BTC", "USDC", MarketType::CollateralIsBase)
371        );
372    }
373
374    #[test]
375    fn round_trip_market_id_collateral_is_quote() {
376        let orig = MarketId::new("BTC", "USD", MarketType::CollateralIsQuote);
377        assert_eq!(orig.as_str(), "BTC_USD+");
378        let parsed: MarketId = "BTC_USD+".parse().unwrap();
379        assert_eq!(orig, parsed);
380
381        assert_eq!(orig.get_base(), "BTC");
382        assert_eq!(orig.get_quote(), "USD");
383        assert_eq!(orig.get_notional(), "BTC");
384        assert_eq!(orig.get_collateral(), "USD");
385    }
386
387    #[test]
388    fn round_trip_market_id_usd_collateral_is_base() {
389        let orig = MarketId::new("BTC", "USD", MarketType::CollateralIsBase);
390        assert_eq!(orig.as_str(), "BTC_USD");
391        let parsed: MarketId = "BTC_USD".parse().unwrap();
392        assert_eq!(orig, parsed);
393
394        assert_eq!(orig.get_base(), "BTC");
395        assert_eq!(orig.get_quote(), "USD");
396        assert_eq!(orig.get_notional(), "USD");
397        assert_eq!(orig.get_collateral(), "BTC");
398    }
399
400    #[test]
401    fn notional_to_usd() {
402        // Assume ATOM is notional/base, OSMO is collateral/quote
403        let market_id = MarketId::from_str("ATOM_OSMO").unwrap();
404
405        // $2 per OSMO
406        let price_usd = PriceCollateralInUsd::from_str("2").unwrap();
407
408        // And 5 OSMO to one ATOM, e.g. $10 per ATOM
409        let price_base = PriceBaseInQuote::from_str("5").unwrap();
410
411        let price_point = PricePoint {
412            price_notional: price_base.into_notional_price(market_id.get_market_type()),
413            price_usd,
414            price_base,
415            timestamp: Default::default(),
416            is_notional_usd: market_id.is_notional_usd(),
417            market_type: market_id.get_market_type(),
418            publish_time: None,
419            publish_time_usd: None,
420        };
421
422        let in_usd = price_point.notional_to_usd("50".parse().unwrap());
423
424        // 50 ATOM should be $500
425        assert_eq!(in_usd, "500".parse().unwrap());
426    }
427}