Skip to main content

hl_types/
util.rs

1use std::borrow::Cow;
2use std::str::FromStr;
3
4use rust_decimal::Decimal;
5
6use crate::order::Side;
7use crate::HlError;
8
9/// Parse a `serde_json::Value` as a `Decimal`.
10///
11/// Accepts string-encoded decimals, JSON numbers, and handles `None`/`Null`.
12pub fn parse_str_decimal(val: Option<&serde_json::Value>, field: &str) -> Result<Decimal, HlError> {
13    match val {
14        Some(serde_json::Value::String(s)) => Decimal::from_str(s).map_err(|_| {
15            HlError::Parse(format!("cannot parse '{field}' value \"{s}\" as Decimal"))
16        }),
17        Some(serde_json::Value::Number(n)) => {
18            let s = n.to_string();
19            Decimal::from_str(&s)
20                .map_err(|_| HlError::Parse(format!("cannot convert '{field}' number to Decimal")))
21        }
22        Some(serde_json::Value::Null) | None => {
23            Err(HlError::Parse(format!("missing field '{field}'")))
24        }
25        Some(v) => Err(HlError::Parse(format!(
26            "unexpected type for '{field}': expected string or number, got {v}"
27        ))),
28    }
29}
30
31/// Normalize a coin symbol by stripping common suffixes and uppercasing.
32///
33/// Removes `-PERP`, `-USDC`, and `-USD` suffixes and converts to uppercase
34/// so that e.g. `"BTC-PERP"` becomes `"BTC"` and `"btc"` becomes `"BTC"`.
35///
36/// Returns `Cow::Borrowed` when the input is already uppercase and has no
37/// suffix to strip, avoiding a heap allocation in the common case.
38pub fn normalize_coin(coin: &str) -> Cow<'_, str> {
39    let s = coin.trim();
40
41    // Uppercase first so suffix matching is case-insensitive.
42    let upper = s.to_ascii_uppercase();
43
44    // Try stripping suffixes.
45    for suffix in &["-PERP", "-USDC", "-USD"] {
46        if let Some(stripped) = upper.strip_suffix(suffix) {
47            return Cow::Owned(stripped.to_string());
48        }
49    }
50
51    // No suffix — borrow if input was already uppercase.
52    if upper == s {
53        Cow::Borrowed(s)
54    } else {
55        Cow::Owned(upper)
56    }
57}
58
59/// Parse the mid price from an `l2Book` JSON response.
60///
61/// Extracts the best bid and best ask from the `levels` array and returns
62/// `(best_bid + best_ask) / 2`.
63pub fn parse_mid_price_from_l2book(resp: &serde_json::Value) -> Result<Decimal, HlError> {
64    let levels = resp
65        .get("levels")
66        .and_then(|v| v.as_array())
67        .ok_or_else(|| HlError::Parse("l2Book response missing 'levels' array".into()))?;
68
69    if levels.len() < 2 {
70        return Err(HlError::Parse(
71            "l2Book 'levels' array has fewer than 2 entries".into(),
72        ));
73    }
74
75    let best_bid = levels[0]
76        .as_array()
77        .and_then(|a| a.first())
78        .and_then(|e| e.get("px"))
79        .and_then(|v| v.as_str())
80        .ok_or_else(|| HlError::Parse("l2Book: missing best bid price".into()))?;
81
82    let best_ask = levels[1]
83        .as_array()
84        .and_then(|a| a.first())
85        .and_then(|e| e.get("px"))
86        .and_then(|v| v.as_str())
87        .ok_or_else(|| HlError::Parse("l2Book: missing best ask price".into()))?;
88
89    let bid: Decimal = Decimal::from_str(best_bid)
90        .map_err(|e| HlError::Parse(format!("l2Book: invalid bid price '{}': {}", best_bid, e)))?;
91    let ask: Decimal = Decimal::from_str(best_ask)
92        .map_err(|e| HlError::Parse(format!("l2Book: invalid ask price '{}': {}", best_ask, e)))?;
93
94    Ok((bid + ask) / Decimal::from(2))
95}
96
97/// Parse a position's size and side from a `clearinghouseState` JSON response.
98///
99/// Searches the `assetPositions` array for a matching coin and returns
100/// `(side, abs_size)` where side is `Buy` (long) for positive szi and
101/// `Sell` (short) for negative szi.
102pub fn parse_position_szi(
103    resp: &serde_json::Value,
104    coin: &str,
105) -> Result<(Side, Decimal), HlError> {
106    let positions = resp
107        .get("assetPositions")
108        .and_then(|v| v.as_array())
109        .ok_or_else(|| HlError::Parse("clearinghouseState: missing 'assetPositions'".into()))?;
110
111    for pos in positions {
112        let position = &pos["position"];
113        let pos_coin = position.get("coin").and_then(|v| v.as_str()).unwrap_or("");
114        if pos_coin.to_uppercase() != coin.to_uppercase() {
115            continue;
116        }
117        let szi_str = position
118            .get("szi")
119            .and_then(|v| v.as_str())
120            .ok_or_else(|| HlError::Parse("clearinghouseState: missing 'szi' field".into()))?;
121        let szi: Decimal = Decimal::from_str(szi_str).map_err(|e| {
122            HlError::Parse(format!(
123                "clearinghouseState: invalid szi '{}': {}",
124                szi_str, e
125            ))
126        })?;
127
128        if szi.is_zero() {
129            return Err(HlError::Parse(format!(
130                "market_close: position size for {} is zero",
131                coin
132            )));
133        }
134
135        let side = if szi > Decimal::ZERO {
136            Side::Buy // long
137        } else {
138            Side::Sell // short
139        };
140        return Ok((side, szi.abs()));
141    }
142
143    Err(HlError::Parse(format!(
144        "market_close: no open position found for {}",
145        coin
146    )))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn strip_perp() {
155        assert_eq!(normalize_coin("BTC-PERP"), "BTC");
156    }
157
158    #[test]
159    fn strip_usdc() {
160        assert_eq!(normalize_coin("ETH-USDC"), "ETH");
161    }
162
163    #[test]
164    fn strip_usd() {
165        assert_eq!(normalize_coin("SOL-USD"), "SOL");
166    }
167
168    #[test]
169    fn no_suffix() {
170        assert_eq!(normalize_coin("BTC"), "BTC");
171    }
172
173    #[test]
174    fn handles_whitespace() {
175        assert_eq!(normalize_coin("  BTC-PERP  "), "BTC");
176    }
177
178    #[test]
179    fn longest_suffix_wins() {
180        // "-USDC" should be stripped, not just "-USD"
181        assert_eq!(normalize_coin("ETH-USDC"), "ETH");
182    }
183
184    // -- parse_mid_price_from_l2book tests --
185
186    fn l2book_json(bid: &str, ask: &str) -> serde_json::Value {
187        serde_json::json!({
188            "levels": [
189                [{"px": bid, "sz": "1.0", "n": 1}],
190                [{"px": ask, "sz": "1.0", "n": 1}]
191            ]
192        })
193    }
194
195    #[test]
196    fn mid_price_basic() {
197        let resp = l2book_json("90000", "90100");
198        let mid = parse_mid_price_from_l2book(&resp).unwrap();
199        assert_eq!(mid, Decimal::from_str("90050").unwrap());
200    }
201
202    #[test]
203    fn mid_price_decimal_values() {
204        let resp = l2book_json("1.50", "2.50");
205        let mid = parse_mid_price_from_l2book(&resp).unwrap();
206        assert_eq!(mid, Decimal::from(2));
207    }
208
209    #[test]
210    fn mid_price_missing_levels() {
211        let resp = serde_json::json!({});
212        assert!(parse_mid_price_from_l2book(&resp).is_err());
213    }
214
215    #[test]
216    fn mid_price_too_few_levels() {
217        let resp = serde_json::json!({ "levels": [[{"px": "100", "sz": "1"}]] });
218        assert!(parse_mid_price_from_l2book(&resp).is_err());
219    }
220
221    #[test]
222    fn mid_price_empty_bid_level() {
223        let resp = serde_json::json!({
224            "levels": [
225                [],
226                [{"px": "100", "sz": "1"}]
227            ]
228        });
229        assert!(parse_mid_price_from_l2book(&resp).is_err());
230    }
231
232    // -- parse_position_szi tests --
233
234    fn clearinghouse_json(coin: &str, szi: &str) -> serde_json::Value {
235        serde_json::json!({
236            "assetPositions": [
237                {
238                    "position": {
239                        "coin": coin,
240                        "szi": szi
241                    }
242                }
243            ]
244        })
245    }
246
247    #[test]
248    fn position_szi_long() {
249        let resp = clearinghouse_json("BTC", "1.5");
250        let (side, size) = parse_position_szi(&resp, "BTC").unwrap();
251        assert_eq!(side, Side::Buy);
252        assert_eq!(size, Decimal::from_str("1.5").unwrap());
253    }
254
255    #[test]
256    fn position_szi_short() {
257        let resp = clearinghouse_json("ETH", "-2.0");
258        let (side, size) = parse_position_szi(&resp, "ETH").unwrap();
259        assert_eq!(side, Side::Sell);
260        assert_eq!(size, Decimal::from(2));
261    }
262
263    #[test]
264    fn position_szi_case_insensitive() {
265        let resp = clearinghouse_json("btc", "0.5");
266        let (side, _) = parse_position_szi(&resp, "BTC").unwrap();
267        assert_eq!(side, Side::Buy);
268    }
269
270    #[test]
271    fn position_szi_zero_errors() {
272        let resp = clearinghouse_json("BTC", "0");
273        assert!(parse_position_szi(&resp, "BTC").is_err());
274    }
275
276    #[test]
277    fn position_szi_not_found() {
278        let resp = clearinghouse_json("BTC", "1.0");
279        assert!(parse_position_szi(&resp, "ETH").is_err());
280    }
281
282    #[test]
283    fn position_szi_missing_asset_positions() {
284        let resp = serde_json::json!({});
285        assert!(parse_position_szi(&resp, "BTC").is_err());
286    }
287
288    #[test]
289    fn lowercase_input_uppercased() {
290        assert_eq!(normalize_coin("btc-PERP"), "BTC");
291    }
292
293    #[test]
294    fn mixed_case_no_suffix() {
295        assert_eq!(normalize_coin("Eth"), "ETH");
296    }
297
298    #[test]
299    fn already_uppercase_borrows() {
300        let result = normalize_coin("BTC");
301        assert!(matches!(result, Cow::Borrowed(_)));
302        assert_eq!(result, "BTC");
303    }
304
305    #[test]
306    fn lowercase_allocates() {
307        let result = normalize_coin("btc");
308        assert!(matches!(result, Cow::Owned(_)));
309        assert_eq!(result, "BTC");
310    }
311
312    #[test]
313    fn suffix_stripped_allocates() {
314        let result = normalize_coin("BTC-PERP");
315        assert!(matches!(result, Cow::Owned(_)));
316        assert_eq!(result, "BTC");
317    }
318
319    #[test]
320    fn lowercase_suffix_stripped() {
321        assert_eq!(normalize_coin("BTC-perp"), "BTC");
322        assert_eq!(normalize_coin("eth-usdc"), "ETH");
323        assert_eq!(normalize_coin("sol-usd"), "SOL");
324    }
325}