Skip to main content

digdigdig3/testing/suites/
positions.rs

1//! # Positions Suite
2//!
3//! Tests for the `Positions` trait: get_positions, get_funding_rate,
4//! get_mark_price, get_open_interest, get_long_short_ratio.
5//!
6//! All methods in this suite are **read-only** — no positions are opened or
7//! modified. Authentication is required (the `Positions` trait is private).
8
9use std::time::Instant;
10
11use crate::core::traits::{ExchangeIdentity, Positions};
12use crate::core::types::{AccountType, PositionQuery, Symbol};
13
14use super::{assert_position_sane, is_auth_error, is_unsupported, TestResult};
15
16// ═══════════════════════════════════════════════════════════════════════════════
17// RUN ALL
18// ═══════════════════════════════════════════════════════════════════════════════
19
20/// Run all positions suite tests against `connector`.
21///
22/// `symbol` — the perpetual/futures trading pair to use.
23/// `account_type` — typically `AccountType::FuturesCross` or `FuturesIsolated`.
24///
25/// Returns one `TestResult` per test function.
26pub async fn run_all(
27    connector: &(dyn PositionsConnector + Send + Sync),
28    symbol: Symbol,
29    account_type: AccountType,
30) -> Vec<TestResult> {
31    let mut results = Vec::new();
32
33    results.push(test_get_positions(connector, symbol.clone(), account_type).await);
34    results.push(test_get_funding_rate(connector, symbol.clone(), account_type).await);
35    results.push(test_get_mark_price(connector, symbol.clone()).await);
36    results.push(test_get_open_interest(connector, symbol.clone(), account_type).await);
37    results.push(test_get_long_short_ratio(connector, symbol.clone(), account_type).await);
38
39    results
40}
41
42// ═══════════════════════════════════════════════════════════════════════════════
43// HELPER SUPERTRAIT
44// ═══════════════════════════════════════════════════════════════════════════════
45
46/// Combined supertrait required by all positions tests.
47pub trait PositionsConnector: Positions + ExchangeIdentity {}
48
49impl<T: Positions + ExchangeIdentity> PositionsConnector for T {}
50
51// ═══════════════════════════════════════════════════════════════════════════════
52// TEST: get_positions
53// ═══════════════════════════════════════════════════════════════════════════════
54
55/// Fetch open positions for `symbol` and sanity-check each entry.
56///
57/// An empty result is valid — the account may have no open positions.
58/// Each returned position is validated with `assert_position_sane`.
59pub async fn test_get_positions(
60    connector: &(dyn PositionsConnector + Send + Sync),
61    symbol: Symbol,
62    account_type: AccountType,
63) -> TestResult {
64    const NAME: &str = "test_get_positions";
65    let exchange = connector.exchange_name();
66    let start = Instant::now();
67
68    let query = PositionQuery {
69        symbol: Some(symbol),
70        account_type,
71    };
72
73    match connector.get_positions(query).await {
74        Ok(positions) => {
75            for pos in &positions {
76                if let Err(reason) = assert_position_sane(pos) {
77                    return TestResult::fail(
78                        NAME, exchange,
79                        start.elapsed().as_millis() as u64,
80                        reason,
81                    );
82                }
83            }
84            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
85        }
86        Err(err) if is_unsupported(&err) => {
87            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
88                format!("get_positions unsupported: {err}"))
89        }
90        Err(err) if is_auth_error(&err) => {
91            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
92                format!("auth error: {err}"))
93        }
94        Err(err) => {
95            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
96                format!("get_positions failed: {err}"))
97        }
98    }
99}
100
101// ═══════════════════════════════════════════════════════════════════════════════
102// TEST: get_funding_rate
103// ═══════════════════════════════════════════════════════════════════════════════
104
105/// Fetch the current funding rate for `symbol` and validate it is reasonable.
106///
107/// Checks:
108/// - `-1.0 < rate < 1.0` — sane funding rate range (0.01% = 0.0001 typical).
109/// - `next_funding_time > 0` if the field is present.
110pub async fn test_get_funding_rate(
111    connector: &(dyn PositionsConnector + Send + Sync),
112    symbol: Symbol,
113    account_type: AccountType,
114) -> TestResult {
115    const NAME: &str = "test_get_funding_rate";
116    let exchange = connector.exchange_name();
117    let start = Instant::now();
118
119    match connector.get_funding_rate(&symbol.to_concat(), account_type).await {
120        Ok(fr) => {
121            if fr.rate.is_nan() || fr.rate.is_infinite() {
122                return TestResult::fail(
123                    NAME, exchange,
124                    start.elapsed().as_millis() as u64,
125                    format!("funding rate is NaN or infinite: {}", fr.rate),
126                );
127            }
128            if fr.rate <= -1.0 || fr.rate >= 1.0 {
129                return TestResult::fail(
130                    NAME, exchange,
131                    start.elapsed().as_millis() as u64,
132                    format!("funding rate out of reasonable range: {}", fr.rate),
133                );
134            }
135            if let Some(nft) = fr.next_funding_time {
136                if nft <= 0 {
137                    return TestResult::fail(
138                        NAME, exchange,
139                        start.elapsed().as_millis() as u64,
140                        format!("next_funding_time must be positive, got {nft}"),
141                    );
142                }
143            }
144            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
145        }
146        Err(err) if is_unsupported(&err) => {
147            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
148                format!("get_funding_rate unsupported: {err}"))
149        }
150        Err(err) if is_auth_error(&err) => {
151            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
152                format!("auth error: {err}"))
153        }
154        Err(err) => {
155            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
156                format!("get_funding_rate failed: {err}"))
157        }
158    }
159}
160
161// ═══════════════════════════════════════════════════════════════════════════════
162// TEST: get_mark_price
163// ═══════════════════════════════════════════════════════════════════════════════
164
165/// Fetch the mark price for `symbol` and validate it is positive.
166pub async fn test_get_mark_price(
167    connector: &(dyn PositionsConnector + Send + Sync),
168    symbol: Symbol,
169) -> TestResult {
170    const NAME: &str = "test_get_mark_price";
171    let exchange = connector.exchange_name();
172    let start = Instant::now();
173
174    match connector.get_mark_price(&symbol.to_concat()).await {
175        Ok(mp) => {
176            if mp.mark_price.is_nan()
177                || mp.mark_price.is_infinite()
178                || mp.mark_price <= 0.0
179            {
180                return TestResult::fail(
181                    NAME, exchange,
182                    start.elapsed().as_millis() as u64,
183                    format!("mark_price invalid: {}", mp.mark_price),
184                );
185            }
186            // Index price, if present, should also be positive.
187            if let Some(idx) = mp.index_price {
188                if idx.is_nan() || idx.is_infinite() || idx <= 0.0 {
189                    return TestResult::fail(
190                        NAME, exchange,
191                        start.elapsed().as_millis() as u64,
192                        format!("index_price invalid: {idx}"),
193                    );
194                }
195            }
196            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
197        }
198        Err(err) if is_unsupported(&err) => {
199            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
200                format!("get_mark_price unsupported: {err}"))
201        }
202        Err(err) if is_auth_error(&err) => {
203            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
204                format!("auth error: {err}"))
205        }
206        Err(err) => {
207            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
208                format!("get_mark_price failed: {err}"))
209        }
210    }
211}
212
213// ═══════════════════════════════════════════════════════════════════════════════
214// TEST: get_open_interest
215// ═══════════════════════════════════════════════════════════════════════════════
216
217/// Fetch open interest for `symbol` and validate it is positive.
218///
219/// For popular symbols (BTC, ETH) open interest should always be > 0.
220pub async fn test_get_open_interest(
221    connector: &(dyn PositionsConnector + Send + Sync),
222    symbol: Symbol,
223    account_type: AccountType,
224) -> TestResult {
225    const NAME: &str = "test_get_open_interest";
226    let exchange = connector.exchange_name();
227    let start = Instant::now();
228
229    match connector.get_open_interest(&symbol.to_concat(), account_type).await {
230        Ok(oi) => {
231            if oi.open_interest.is_nan()
232                || oi.open_interest.is_infinite()
233                || oi.open_interest < 0.0
234            {
235                return TestResult::fail(
236                    NAME, exchange,
237                    start.elapsed().as_millis() as u64,
238                    format!("open_interest invalid: {}", oi.open_interest),
239                );
240            }
241            // For major pairs open interest should be non-zero.
242            let base_upper = symbol.base.to_uppercase();
243            let is_major = matches!(base_upper.as_str(), "BTC" | "ETH" | "SOL" | "BNB");
244            if is_major && oi.open_interest == 0.0 {
245                return TestResult::fail(
246                    NAME, exchange,
247                    start.elapsed().as_millis() as u64,
248                    format!("open_interest is 0 for major symbol {symbol}"),
249                );
250            }
251            // USD value, if present, must be non-negative.
252            if let Some(v) = oi.open_interest_value {
253                if v.is_nan() || v.is_infinite() || v < 0.0 {
254                    return TestResult::fail(
255                        NAME, exchange,
256                        start.elapsed().as_millis() as u64,
257                        format!("open_interest_value invalid: {v}"),
258                    );
259                }
260            }
261            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
262        }
263        Err(err) if is_unsupported(&err) => {
264            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
265                format!("get_open_interest unsupported: {err}"))
266        }
267        Err(err) if is_auth_error(&err) => {
268            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
269                format!("auth error: {err}"))
270        }
271        Err(err) => {
272            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
273                format!("get_open_interest failed: {err}"))
274        }
275    }
276}
277
278// ═══════════════════════════════════════════════════════════════════════════════
279// TEST: get_long_short_ratio
280// ═══════════════════════════════════════════════════════════════════════════════
281
282/// Fetch the long/short ratio for `symbol` and validate both ratios are in [0, 1].
283pub async fn test_get_long_short_ratio(
284    connector: &(dyn PositionsConnector + Send + Sync),
285    symbol: Symbol,
286    account_type: AccountType,
287) -> TestResult {
288    const NAME: &str = "test_get_long_short_ratio";
289    let exchange = connector.exchange_name();
290    let start = Instant::now();
291
292    match connector.get_long_short_ratio(&symbol.to_concat(), account_type).await {
293        Ok(lsr) => {
294            if lsr.long_ratio < 0.0 || lsr.long_ratio.is_nan() {
295                return TestResult::fail(
296                    NAME, exchange,
297                    start.elapsed().as_millis() as u64,
298                    format!("long_ratio invalid: {}", lsr.long_ratio),
299                );
300            }
301            if lsr.short_ratio < 0.0 || lsr.short_ratio.is_nan() {
302                return TestResult::fail(
303                    NAME, exchange,
304                    start.elapsed().as_millis() as u64,
305                    format!("short_ratio invalid: {}", lsr.short_ratio),
306                );
307            }
308            // The two ratios should approximately sum to 1.0 (within 1%).
309            let sum = lsr.long_ratio + lsr.short_ratio;
310            if (sum - 1.0).abs() > 0.01 {
311                return TestResult::fail(
312                    NAME, exchange,
313                    start.elapsed().as_millis() as u64,
314                    format!(
315                        "long_ratio + short_ratio = {sum:.4}, expected ~1.0 \
316                         (long={}, short={})",
317                        lsr.long_ratio, lsr.short_ratio
318                    ),
319                );
320            }
321            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
322        }
323        Err(err) if is_unsupported(&err) => {
324            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
325                format!("get_long_short_ratio unsupported: {err}"))
326        }
327        Err(err) if is_auth_error(&err) => {
328            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
329                format!("auth error: {err}"))
330        }
331        Err(err) => {
332            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
333                format!("get_long_short_ratio failed: {err}"))
334        }
335    }
336}