1use anyhow::{Context, Result};
4use tracing::{debug, warn};
5
6use super::hydromancer_types::{HydromancerPriceRecord, OraclePriceHistoryRequest};
7use super::hyperliquid_oracle::PriceSample;
8
9#[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
34pub 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 #[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 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 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}