1use std::borrow::Cow;
2use std::str::FromStr;
3
4use rust_decimal::Decimal;
5
6use crate::order::Side;
7use crate::HlError;
8
9pub 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
31pub fn normalize_coin(coin: &str) -> Cow<'_, str> {
39 let s = coin.trim();
40
41 let upper = s.to_ascii_uppercase();
43
44 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 if upper == s {
53 Cow::Borrowed(s)
54 } else {
55 Cow::Owned(upper)
56 }
57}
58
59pub 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
97pub 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 } else {
138 Side::Sell };
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 assert_eq!(normalize_coin("ETH-USDC"), "ETH");
182 }
183
184 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 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}