Skip to content

Commit 9eed492

Browse files
Herklosclaude
andcommitted
Add deep smoke test validators and expand pro exchange coverage
Create shared test helper module (tests/common/mod.rs) with thorough validators for ticker, order book, and OHLCV data modeled after the canonical CCXT TypeScript test suite. Validates prices > 0, volumes >= 0, bid-ask spread, OHLC bounds, timestamp sanity, symbol matching, and sort order for order book entries. Expand pro exchange smoke tests from 27 to 71 exchanges covering okx, bybit, kraken, kucoin, gate, hyperliquid, phemex, dydx, coinbase, and 30+ more. Refactor both test files to use shared validators. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 705e9a5 commit 9eed492

3 files changed

Lines changed: 907 additions & 111 deletions

File tree

rust/tests/common/mod.rs

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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

Comments
 (0)