|
| 1 | +#![allow(dead_code)] |
| 2 | + |
| 3 | +use serde_json::Value as JsonValue; |
| 4 | +use std::net::ToSocketAddrs; |
| 5 | + |
| 6 | +// --------------------------------------------------------------------------- |
| 7 | +// Helper utilities |
| 8 | +// --------------------------------------------------------------------------- |
| 9 | + |
| 10 | +/// Extract f64 from a JSON object by string key. Handles both numeric and |
| 11 | +/// string-encoded values (e.g. `"123.45"`). |
| 12 | +pub fn safe_f64(obj: &JsonValue, key: &str) -> Option<f64> { |
| 13 | + let v = obj.get(key)?; |
| 14 | + v.as_f64().or_else(|| v.as_str()?.parse::<f64>().ok()) |
| 15 | +} |
| 16 | + |
| 17 | +/// Extract i64 from a JSON object by string key. |
| 18 | +pub fn safe_i64(obj: &JsonValue, key: &str) -> Option<i64> { |
| 19 | + let v = obj.get(key)?; |
| 20 | + v.as_i64().or_else(|| v.as_str()?.parse::<i64>().ok()) |
| 21 | +} |
| 22 | + |
| 23 | +/// Extract f64 from a JSON array by index. Handles string-encoded numbers. |
| 24 | +pub fn safe_f64_at(arr: &JsonValue, index: usize) -> Option<f64> { |
| 25 | + let v = arr.get(index)?; |
| 26 | + v.as_f64().or_else(|| v.as_str()?.parse::<f64>().ok()) |
| 27 | +} |
| 28 | + |
| 29 | +/// Extract i64 from a JSON array by index. |
| 30 | +pub fn safe_i64_at(arr: &JsonValue, index: usize) -> Option<i64> { |
| 31 | + let v = arr.get(index)?; |
| 32 | + v.as_i64().or_else(|| v.as_str()?.parse::<i64>().ok()) |
| 33 | +} |
| 34 | + |
| 35 | +/// Check if a JSON value is defined (not null and not absent). |
| 36 | +fn is_defined(obj: &JsonValue, key: &str) -> bool { |
| 37 | + obj.get(key).map(|v| !v.is_null()).unwrap_or(false) |
| 38 | +} |
| 39 | + |
| 40 | +pub fn network_available() -> bool { |
| 41 | + ("api.binance.com", 443).to_socket_addrs().is_ok() |
| 42 | +} |
| 43 | + |
| 44 | +// --------------------------------------------------------------------------- |
| 45 | +// Timestamp sanity: between Jan 2009 and Jan 2038 |
| 46 | +// --------------------------------------------------------------------------- |
| 47 | + |
| 48 | +const TS_MIN_MS: i64 = 1_230_940_800_000; // Jan 2009 in milliseconds |
| 49 | +const TS_MAX_MS: i64 = 2_147_483_648_000; // Jan 2038 in milliseconds |
| 50 | +const TS_MIN_S: i64 = 1_230_940_800; // Jan 2009 in seconds |
| 51 | +const TS_MAX_S: i64 = 2_147_483_648; // Jan 2038 in seconds |
| 52 | + |
| 53 | +fn assert_timestamp_sane(exchange: &str, context: &str, ts: i64) { |
| 54 | + // Handle both millisecond (13+ digits) and second (10 digits) timestamps |
| 55 | + let valid = if ts > TS_MIN_MS { |
| 56 | + ts < TS_MAX_MS |
| 57 | + } else { |
| 58 | + ts > TS_MIN_S && ts < TS_MAX_S |
| 59 | + }; |
| 60 | + assert!( |
| 61 | + valid, |
| 62 | + "{exchange}: {context} timestamp {ts} out of sane range" |
| 63 | + ); |
| 64 | +} |
| 65 | + |
| 66 | +// --------------------------------------------------------------------------- |
| 67 | +// describe() |
| 68 | +// --------------------------------------------------------------------------- |
| 69 | + |
| 70 | +pub fn assert_describe_shape(exchange: &str, describe: &JsonValue, require_pro: bool) { |
| 71 | + assert!(describe.get("id").is_some(), "{exchange}: describe.id missing"); |
| 72 | + assert!(describe.get("name").is_some(), "{exchange}: describe.name missing"); |
| 73 | + assert!(describe.get("has").is_some(), "{exchange}: describe.has missing"); |
| 74 | + assert!(describe.get("api").is_some(), "{exchange}: describe.api missing"); |
| 75 | + assert!(describe.get("urls").is_some(), "{exchange}: describe.urls missing"); |
| 76 | + if require_pro { |
| 77 | + let rate_limit = describe.get("rateLimit").and_then(|v| v.as_f64()); |
| 78 | + assert!( |
| 79 | + rate_limit.map(|r| r > 0.0).unwrap_or(false), |
| 80 | + "{exchange}: rateLimit should be a positive number" |
| 81 | + ); |
| 82 | + assert_eq!( |
| 83 | + describe.get("pro").and_then(|v| v.as_bool()), |
| 84 | + Some(true), |
| 85 | + "{exchange}: expected pro: true in describe()" |
| 86 | + ); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +// --------------------------------------------------------------------------- |
| 91 | +// Ticker |
| 92 | +// --------------------------------------------------------------------------- |
| 93 | + |
| 94 | +pub fn assert_ticker_shape(exchange: &str, ticker: Option<JsonValue>, expected_symbol: &str) { |
| 95 | + let v = match ticker { |
| 96 | + Some(v) => v, |
| 97 | + None => return, |
| 98 | + }; |
| 99 | + let obj = match v.as_object() { |
| 100 | + Some(o) => o, |
| 101 | + None => return, // not an object — nothing to validate |
| 102 | + }; |
| 103 | + |
| 104 | + // 1. Key presence — soft check (raw exchange responses may lack unified keys) |
| 105 | + let has_unified_keys = obj.contains_key("symbol") && obj.contains_key("last"); |
| 106 | + |
| 107 | + // 2. Symbol match (only if unified key is present) |
| 108 | + if let Some(sym) = obj.get("symbol").and_then(|s| s.as_str()) { |
| 109 | + if has_unified_keys { |
| 110 | + assert_eq!( |
| 111 | + sym, expected_symbol, |
| 112 | + "{exchange}: ticker.symbol '{sym}' != expected '{expected_symbol}'" |
| 113 | + ); |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // 3. last == close (if both defined) |
| 118 | + if let (Some(last), Some(close)) = (safe_f64(&v, "last"), safe_f64(&v, "close")) { |
| 119 | + assert!( |
| 120 | + (last - close).abs() < 1e-12, |
| 121 | + "{exchange}: ticker.last ({last}) != ticker.close ({close})" |
| 122 | + ); |
| 123 | + } |
| 124 | + |
| 125 | + // 4. Prices > 0 (check both unified and common raw exchange keys) |
| 126 | + for key in &[ |
| 127 | + "open", "high", "low", "close", "ask", "bid", "last", |
| 128 | + "openPrice", "highPrice", "lowPrice", "lastPrice", "askPrice", "bidPrice", |
| 129 | + ] { |
| 130 | + if let Some(price) = safe_f64(&v, key) { |
| 131 | + assert!( |
| 132 | + price > 0.0, |
| 133 | + "{exchange}: ticker.{key} ({price}) should be > 0" |
| 134 | + ); |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + // 5. Volumes >= 0 |
| 139 | + for key in &["askVolume", "bidVolume", "baseVolume", "quoteVolume", "volume", "quoteVolume"] { |
| 140 | + if let Some(vol) = safe_f64(&v, key) { |
| 141 | + assert!( |
| 142 | + vol >= 0.0, |
| 143 | + "{exchange}: ticker.{key} ({vol}) should be >= 0" |
| 144 | + ); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + // 6. Spread: ask > bid (try unified keys, then raw exchange keys) |
| 149 | + let ask_val = safe_f64(&v, "ask").or_else(|| safe_f64(&v, "askPrice")); |
| 150 | + let bid_val = safe_f64(&v, "bid").or_else(|| safe_f64(&v, "bidPrice")); |
| 151 | + if let (Some(ask), Some(bid)) = (ask_val, bid_val) { |
| 152 | + assert!( |
| 153 | + ask > bid, |
| 154 | + "{exchange}: ticker ask ({ask}) should be > bid ({bid})" |
| 155 | + ); |
| 156 | + } |
| 157 | + |
| 158 | + // 7. OHLC bounds (try unified keys, then raw exchange keys) |
| 159 | + let high_val = safe_f64(&v, "high").or_else(|| safe_f64(&v, "highPrice")); |
| 160 | + let low_val = safe_f64(&v, "low").or_else(|| safe_f64(&v, "lowPrice")); |
| 161 | + if let (Some(high), Some(low)) = (high_val, low_val) { |
| 162 | + if let Some(open) = safe_f64(&v, "open").or_else(|| safe_f64(&v, "openPrice")) { |
| 163 | + assert!( |
| 164 | + open >= low && open <= high, |
| 165 | + "{exchange}: ticker open ({open}) should be between low ({low}) and high ({high})" |
| 166 | + ); |
| 167 | + } |
| 168 | + if let Some(close) = safe_f64(&v, "close").or_else(|| safe_f64(&v, "lastPrice")) { |
| 169 | + assert!( |
| 170 | + close >= low && close <= high, |
| 171 | + "{exchange}: ticker close ({close}) should be between low ({low}) and high ({high})" |
| 172 | + ); |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // 8. Percentage range |
| 177 | + if let Some(pct) = safe_f64(&v, "percentage") { |
| 178 | + assert!( |
| 179 | + pct >= -100.0 && pct <= 10000.0, |
| 180 | + "{exchange}: ticker.percentage ({pct}) out of range [-100, 10000]" |
| 181 | + ); |
| 182 | + } |
| 183 | + |
| 184 | + // 9. VWAP consistency |
| 185 | + if let Some(vwap) = safe_f64(&v, "vwap") { |
| 186 | + assert!( |
| 187 | + vwap >= 0.0, |
| 188 | + "{exchange}: ticker.vwap ({vwap}) should be >= 0" |
| 189 | + ); |
| 190 | + if is_defined(&v, "baseVolume") { |
| 191 | + assert!( |
| 192 | + is_defined(&v, "quoteVolume"), |
| 193 | + "{exchange}: vwap & baseVolume defined but quoteVolume missing" |
| 194 | + ); |
| 195 | + } |
| 196 | + if is_defined(&v, "quoteVolume") { |
| 197 | + assert!( |
| 198 | + is_defined(&v, "baseVolume"), |
| 199 | + "{exchange}: vwap & quoteVolume defined but baseVolume missing" |
| 200 | + ); |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + // 10. Timestamp sanity |
| 205 | + if let Some(ts) = safe_i64(&v, "timestamp") { |
| 206 | + assert_timestamp_sane(exchange, "ticker", ts); |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +// --------------------------------------------------------------------------- |
| 211 | +// Order Book |
| 212 | +// --------------------------------------------------------------------------- |
| 213 | + |
| 214 | +pub fn assert_order_book_shape(exchange: &str, ob: Option<JsonValue>, expected_symbol: &str) { |
| 215 | + let v = match ob { |
| 216 | + Some(v) => v, |
| 217 | + None => return, |
| 218 | + }; |
| 219 | + let obj = match v.as_object() { |
| 220 | + Some(o) => o, |
| 221 | + None => return, |
| 222 | + }; |
| 223 | + |
| 224 | + // 1. Try to find bids/asks arrays (unified or raw format) |
| 225 | + let bids = v.get("bids").and_then(|b| b.as_array()); |
| 226 | + let asks = v.get("asks").and_then(|a| a.as_array()); |
| 227 | + |
| 228 | + // If neither bids nor asks exist, this is raw data we can't validate — return |
| 229 | + let (bids, asks) = match (bids, asks) { |
| 230 | + (Some(b), Some(a)) => (b, a), |
| 231 | + _ => return, |
| 232 | + }; |
| 233 | + |
| 234 | + // 2. Symbol match (if present) |
| 235 | + if let Some(sym) = obj.get("symbol").and_then(|s| s.as_str()) { |
| 236 | + assert_eq!( |
| 237 | + sym, expected_symbol, |
| 238 | + "{exchange}: order_book.symbol '{sym}' != expected '{expected_symbol}'" |
| 239 | + ); |
| 240 | + } |
| 241 | + |
| 242 | + // 4. Bids sorted descending + price/amount > 0 |
| 243 | + for i in 0..bids.len() { |
| 244 | + let price = safe_f64_at(&bids[i], 0); |
| 245 | + let amount = safe_f64_at(&bids[i], 1); |
| 246 | + if let Some(p) = price { |
| 247 | + assert!(p > 0.0, "{exchange}: bids[{i}] price ({p}) should be > 0"); |
| 248 | + } |
| 249 | + if let Some(a) = amount { |
| 250 | + assert!(a > 0.0, "{exchange}: bids[{i}] amount ({a}) should be > 0"); |
| 251 | + } |
| 252 | + if i + 1 < bids.len() { |
| 253 | + if let (Some(cur), Some(next)) = (price, safe_f64_at(&bids[i + 1], 0)) { |
| 254 | + assert!( |
| 255 | + cur > next, |
| 256 | + "{exchange}: bids not sorted descending: bids[{i}]={cur} <= bids[{}]={next}", |
| 257 | + i + 1 |
| 258 | + ); |
| 259 | + } |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + // 5. Asks sorted ascending + price/amount > 0 |
| 264 | + for i in 0..asks.len() { |
| 265 | + let price = safe_f64_at(&asks[i], 0); |
| 266 | + let amount = safe_f64_at(&asks[i], 1); |
| 267 | + if let Some(p) = price { |
| 268 | + assert!(p > 0.0, "{exchange}: asks[{i}] price ({p}) should be > 0"); |
| 269 | + } |
| 270 | + if let Some(a) = amount { |
| 271 | + assert!(a > 0.0, "{exchange}: asks[{i}] amount ({a}) should be > 0"); |
| 272 | + } |
| 273 | + if i + 1 < asks.len() { |
| 274 | + if let (Some(cur), Some(next)) = (price, safe_f64_at(&asks[i + 1], 0)) { |
| 275 | + assert!( |
| 276 | + cur < next, |
| 277 | + "{exchange}: asks not sorted ascending: asks[{i}]={cur} >= asks[{}]={next}", |
| 278 | + i + 1 |
| 279 | + ); |
| 280 | + } |
| 281 | + } |
| 282 | + } |
| 283 | + |
| 284 | + // 6. Bid-ask spread |
| 285 | + if !bids.is_empty() && !asks.is_empty() { |
| 286 | + if let (Some(best_bid), Some(best_ask)) = |
| 287 | + (safe_f64_at(&bids[0], 0), safe_f64_at(&asks[0], 0)) |
| 288 | + { |
| 289 | + assert!( |
| 290 | + best_bid < best_ask, |
| 291 | + "{exchange}: best bid ({best_bid}) should be < best ask ({best_ask})" |
| 292 | + ); |
| 293 | + } |
| 294 | + } |
| 295 | + |
| 296 | + // 7. Timestamp sanity |
| 297 | + if let Some(ts) = safe_i64(&v, "timestamp") { |
| 298 | + assert_timestamp_sane(exchange, "order_book", ts); |
| 299 | + } |
| 300 | +} |
| 301 | + |
| 302 | +// --------------------------------------------------------------------------- |
| 303 | +// OHLCV |
| 304 | +// --------------------------------------------------------------------------- |
| 305 | + |
| 306 | +pub fn assert_ohlcv_shape(exchange: &str, ohlcv: Option<JsonValue>) { |
| 307 | + let v = match ohlcv { |
| 308 | + Some(v) => v, |
| 309 | + None => return, |
| 310 | + }; |
| 311 | + let rows = match v.as_array() { |
| 312 | + Some(r) => r, |
| 313 | + None => return, |
| 314 | + }; |
| 315 | + |
| 316 | + for (idx, row) in rows.iter().take(5).enumerate() { |
| 317 | + let arr = match row.as_array() { |
| 318 | + Some(a) => a, |
| 319 | + None => { |
| 320 | + panic!("{exchange}: ohlcv[{idx}] should be an array"); |
| 321 | + } |
| 322 | + }; |
| 323 | + |
| 324 | + // 1. Length >= 6 |
| 325 | + assert!( |
| 326 | + arr.len() >= 6, |
| 327 | + "{exchange}: ohlcv[{idx}] has {} elements, expected >= 6", |
| 328 | + arr.len() |
| 329 | + ); |
| 330 | + |
| 331 | + // 2. All 6 values defined (not null) |
| 332 | + for i in 0..6 { |
| 333 | + assert!( |
| 334 | + !arr[i].is_null(), |
| 335 | + "{exchange}: ohlcv[{idx}][{i}] should not be null" |
| 336 | + ); |
| 337 | + } |
| 338 | + |
| 339 | + // 3. Timestamp sanity |
| 340 | + if let Some(ts) = safe_i64_at(row, 0) { |
| 341 | + assert_timestamp_sane(exchange, &format!("ohlcv[{idx}]"), ts); |
| 342 | + } |
| 343 | + |
| 344 | + // 4. OHLC bounds: high >= open, high >= close, low <= open, low <= close |
| 345 | + if let (Some(open), Some(high), Some(low), Some(close)) = ( |
| 346 | + safe_f64_at(row, 1), |
| 347 | + safe_f64_at(row, 2), |
| 348 | + safe_f64_at(row, 3), |
| 349 | + safe_f64_at(row, 4), |
| 350 | + ) { |
| 351 | + assert!( |
| 352 | + high >= open, |
| 353 | + "{exchange}: ohlcv[{idx}] high ({high}) < open ({open})" |
| 354 | + ); |
| 355 | + assert!( |
| 356 | + high >= close, |
| 357 | + "{exchange}: ohlcv[{idx}] high ({high}) < close ({close})" |
| 358 | + ); |
| 359 | + assert!( |
| 360 | + low <= open, |
| 361 | + "{exchange}: ohlcv[{idx}] low ({low}) > open ({open})" |
| 362 | + ); |
| 363 | + assert!( |
| 364 | + low <= close, |
| 365 | + "{exchange}: ohlcv[{idx}] low ({low}) > close ({close})" |
| 366 | + ); |
| 367 | + } |
| 368 | + |
| 369 | + // 5. All values >= 0 |
| 370 | + for i in 0..6 { |
| 371 | + if let Some(val) = safe_f64_at(row, i) { |
| 372 | + assert!( |
| 373 | + val >= 0.0, |
| 374 | + "{exchange}: ohlcv[{idx}][{i}] ({val}) should be >= 0" |
| 375 | + ); |
| 376 | + } |
| 377 | + } |
| 378 | + } |
| 379 | +} |
0 commit comments