Skip to main content

hypercall/price_oracle/
hydromancer_client.rs

1// TODO(clients): Move this Hydromancer API client into a dedicated client crate.
2
3use anyhow::{Context, Result};
4use tracing::{debug, warn};
5
6use super::hydromancer_types::{HydromancerPriceRecord, OraclePriceHistoryRequest};
7use super::hyperliquid_oracle::PriceSample;
8
9/// Configuration for the Hydromancer API client.
10#[derive(Clone)]
11pub struct HydromancerConfig {
12    pub api_url: String,
13    pub api_key: String,
14}
15
16impl std::fmt::Debug for HydromancerConfig {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_struct("HydromancerConfig")
19            .field("api_url", &self.api_url)
20            .field("api_key", &"[REDACTED]")
21            .finish()
22    }
23}
24
25impl Default for HydromancerConfig {
26    fn default() -> Self {
27        Self {
28            api_url: "https://api.hydromancer.xyz/info".to_string(),
29            api_key: String::new(),
30        }
31    }
32}
33
34/// HTTP client for querying Hydromancer's historical oracle price API.
35pub struct HydromancerClient {
36    client: reqwest::Client,
37    config: HydromancerConfig,
38}
39
40impl HydromancerClient {
41    pub fn new(config: HydromancerConfig) -> Self {
42        let client = reqwest::Client::builder()
43            .timeout(std::time::Duration::from_secs(10))
44            .build()
45            .expect("Failed to build reqwest client");
46
47        Self { client, config }
48    }
49
50    /// Constructor for tests using mockito base URL.
51    #[cfg(any(test, feature = "test-utils"))]
52    pub fn with_base_url(url: String, api_key: String) -> Self {
53        Self::new(HydromancerConfig {
54            api_url: url,
55            api_key,
56        })
57    }
58
59    /// Fetch oracle price history from Hydromancer for a given coin and time range.
60    pub async fn fetch_oracle_price_history(
61        &self,
62        coin: &str,
63        start_ms: i64,
64        end_ms: i64,
65    ) -> Result<Vec<HydromancerPriceRecord>> {
66        let request = OraclePriceHistoryRequest::new(coin.to_string(), start_ms, end_ms);
67
68        let response = self
69            .client
70            .post(&self.config.api_url)
71            .bearer_auth(&self.config.api_key)
72            .json(&request)
73            .send()
74            .await
75            .context("Hydromancer API request failed")?;
76
77        let status = response.status();
78        if !status.is_success() {
79            let body = response
80                .text()
81                .await
82                .unwrap_or_else(|_| "failed to read body".to_string());
83            anyhow::bail!("Hydromancer API error ({}): {}", status, body);
84        }
85
86        let records: Vec<HydromancerPriceRecord> = response
87            .json()
88            .await
89            .context("Failed to deserialize Hydromancer response")?;
90
91        if records.len() >= 2000 {
92            warn!(
93                "Hydromancer response hit 2000 record limit for coin={}, range=[{}, {}]. Data may be truncated.",
94                coin, start_ms, end_ms
95            );
96        }
97
98        debug!(
99            "Fetched {} Hydromancer records for coin={}, range=[{}, {}]",
100            records.len(),
101            coin,
102            start_ms,
103            end_ms
104        );
105
106        Ok(records)
107    }
108
109    /// Convert Hydromancer records to PriceSamples, filtering invalid entries.
110    ///
111    /// Filters out:
112    /// - Records with null oraclePx
113    /// - Records with unparseable oraclePx
114    /// - Records with non-positive or non-finite prices
115    /// - Records with timestamps outside [window_start_ms, window_end_ms]
116    pub fn records_to_price_samples(
117        records: Vec<HydromancerPriceRecord>,
118        window_start_ms: i64,
119        window_end_ms: i64,
120    ) -> Vec<PriceSample> {
121        let initial_count = records.len();
122        let mut discarded = 0usize;
123
124        let samples: Vec<PriceSample> = records
125            .into_iter()
126            .filter_map(|r| {
127                let oracle_px_str = match r.oracle_px {
128                    Some(ref s) if !s.is_empty() => s,
129                    _ => {
130                        discarded += 1;
131                        return None;
132                    }
133                };
134
135                let price: f64 = match oracle_px_str.parse() {
136                    Ok(p) => p,
137                    Err(_) => {
138                        discarded += 1;
139                        return None;
140                    }
141                };
142
143                if !price.is_finite() || price <= 0.0 {
144                    discarded += 1;
145                    return None;
146                }
147
148                if r.time < window_start_ms || r.time > window_end_ms {
149                    discarded += 1;
150                    return None;
151                }
152
153                Some(PriceSample {
154                    timestamp_ms: r.time,
155                    price,
156                    source: "hydromancer".to_string(),
157                })
158            })
159            .collect();
160
161        if discarded > 0 {
162            warn!(
163                "Discarded {} of {} Hydromancer records (invalid price or out-of-window)",
164                discarded, initial_count
165            );
166        }
167
168        samples
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use mockito::{Matcher, Server};
176
177    #[tokio::test]
178    async fn test_fetch_success() {
179        let mut server = Server::new_async().await;
180
181        let mock = server
182            .mock("POST", "/")
183            .match_header("authorization", "Bearer test-key")
184            .match_body(Matcher::PartialJsonString(
185                r#"{"type":"oraclePriceHistoryByTime"}"#.to_string(),
186            ))
187            .with_status(200)
188            .with_body(
189                r#"[
190                    {"time":1710000000000,"dex":"Hyperliquid","coin":"BTC","oraclePx":"70346.95","markPx":"70350.00","extPerpPx":"70348.00"},
191                    {"time":1710000002000,"dex":"Hyperliquid","coin":"BTC","oraclePx":"70347.50","markPx":"70351.00","extPerpPx":null}
192                ]"#,
193            )
194            .create_async()
195            .await;
196
197        let client = HydromancerClient::with_base_url(server.url(), "test-key".to_string());
198        let records = client
199            .fetch_oracle_price_history("BTC", 1710000000000, 1710001800000)
200            .await
201            .unwrap();
202
203        assert_eq!(records.len(), 2);
204        assert_eq!(records[0].oracle_px, Some("70346.95".to_string()));
205        assert_eq!(records[1].oracle_px, Some("70347.50".to_string()));
206        mock.assert_async().await;
207    }
208
209    #[tokio::test]
210    async fn test_bearer_auth_header() {
211        let mut server = Server::new_async().await;
212
213        let mock = server
214            .mock("POST", "/")
215            .match_header("authorization", "Bearer my-secret-key")
216            .with_status(200)
217            .with_body("[]")
218            .create_async()
219            .await;
220
221        let client = HydromancerClient::with_base_url(server.url(), "my-secret-key".to_string());
222        client
223            .fetch_oracle_price_history("BTC", 0, 1000)
224            .await
225            .unwrap();
226
227        mock.assert_async().await;
228    }
229
230    #[tokio::test]
231    async fn test_api_error_response() {
232        let mut server = Server::new_async().await;
233
234        server
235            .mock("POST", "/")
236            .with_status(401)
237            .with_body(r#"{"error":"Unauthorized"}"#)
238            .create_async()
239            .await;
240
241        let client = HydromancerClient::with_base_url(server.url(), "bad-key".to_string());
242        let result = client.fetch_oracle_price_history("BTC", 0, 1000).await;
243
244        assert!(result.is_err());
245        let err_msg = result.unwrap_err().to_string();
246        assert!(
247            err_msg.contains("401"),
248            "Expected 401 in error: {}",
249            err_msg
250        );
251    }
252
253    #[tokio::test]
254    async fn test_empty_response() {
255        let mut server = Server::new_async().await;
256
257        server
258            .mock("POST", "/")
259            .with_status(200)
260            .with_body("[]")
261            .create_async()
262            .await;
263
264        let client = HydromancerClient::with_base_url(server.url(), "key".to_string());
265        let records = client
266            .fetch_oracle_price_history("BTC", 0, 1000)
267            .await
268            .unwrap();
269
270        assert!(records.is_empty());
271    }
272
273    #[test]
274    fn test_filter_null_oracle_px() {
275        let records = vec![
276            HydromancerPriceRecord {
277                time: 1000,
278                dex: "Hyperliquid".to_string(),
279                coin: "BTC".to_string(),
280                oracle_px: Some("70000.0".to_string()),
281                mark_px: None,
282                ext_perp_px: None,
283            },
284            HydromancerPriceRecord {
285                time: 2000,
286                dex: "Hyperliquid".to_string(),
287                coin: "BTC".to_string(),
288                oracle_px: None,
289                mark_px: None,
290                ext_perp_px: None,
291            },
292            HydromancerPriceRecord {
293                time: 3000,
294                dex: "Hyperliquid".to_string(),
295                coin: "BTC".to_string(),
296                oracle_px: Some("70001.0".to_string()),
297                mark_px: None,
298                ext_perp_px: None,
299            },
300        ];
301
302        let samples = HydromancerClient::records_to_price_samples(records, 0, 5000);
303        assert_eq!(samples.len(), 2);
304        assert_eq!(samples[0].price, 70000.0);
305        assert_eq!(samples[1].price, 70001.0);
306    }
307
308    #[test]
309    fn test_invalid_oracle_px_filtered() {
310        let records = vec![
311            HydromancerPriceRecord {
312                time: 1000,
313                dex: "Hyperliquid".to_string(),
314                coin: "BTC".to_string(),
315                oracle_px: Some("".to_string()),
316                mark_px: None,
317                ext_perp_px: None,
318            },
319            HydromancerPriceRecord {
320                time: 2000,
321                dex: "Hyperliquid".to_string(),
322                coin: "BTC".to_string(),
323                oracle_px: Some("NaN".to_string()),
324                mark_px: None,
325                ext_perp_px: None,
326            },
327            HydromancerPriceRecord {
328                time: 3000,
329                dex: "Hyperliquid".to_string(),
330                coin: "BTC".to_string(),
331                oracle_px: Some("-100.0".to_string()),
332                mark_px: None,
333                ext_perp_px: None,
334            },
335            HydromancerPriceRecord {
336                time: 4000,
337                dex: "Hyperliquid".to_string(),
338                coin: "BTC".to_string(),
339                oracle_px: Some("70000.0".to_string()),
340                mark_px: None,
341                ext_perp_px: None,
342            },
343        ];
344
345        let samples = HydromancerClient::records_to_price_samples(records, 0, 5000);
346        assert_eq!(samples.len(), 1);
347        assert_eq!(samples[0].price, 70000.0);
348    }
349
350    #[test]
351    fn test_out_of_window_timestamps_filtered() {
352        let records = vec![
353            HydromancerPriceRecord {
354                time: 500,
355                dex: "Hyperliquid".to_string(),
356                coin: "BTC".to_string(),
357                oracle_px: Some("70000.0".to_string()),
358                mark_px: None,
359                ext_perp_px: None,
360            },
361            HydromancerPriceRecord {
362                time: 1000,
363                dex: "Hyperliquid".to_string(),
364                coin: "BTC".to_string(),
365                oracle_px: Some("70001.0".to_string()),
366                mark_px: None,
367                ext_perp_px: None,
368            },
369            HydromancerPriceRecord {
370                time: 3000,
371                dex: "Hyperliquid".to_string(),
372                coin: "BTC".to_string(),
373                oracle_px: Some("70002.0".to_string()),
374                mark_px: None,
375                ext_perp_px: None,
376            },
377        ];
378
379        let samples = HydromancerClient::records_to_price_samples(records, 1000, 2000);
380        assert_eq!(samples.len(), 1);
381        assert_eq!(samples[0].price, 70001.0);
382        assert_eq!(samples[0].timestamp_ms, 1000);
383    }
384
385    #[test]
386    fn test_price_sample_source_hydromancer() {
387        let records = vec![HydromancerPriceRecord {
388            time: 1000,
389            dex: "Hyperliquid".to_string(),
390            coin: "BTC".to_string(),
391            oracle_px: Some("70000.0".to_string()),
392            mark_px: None,
393            ext_perp_px: None,
394        }];
395
396        let samples = HydromancerClient::records_to_price_samples(records, 0, 2000);
397        assert_eq!(samples.len(), 1);
398        assert_eq!(samples[0].source, "hydromancer");
399    }
400}