Skip to main content

digdigdig3/testing/suites/
trading.rs

1//! # Trading Suite
2//!
3//! Tests for the `Trading` trait: place_order, cancel_order, get_order,
4//! get_open_orders, get_order_history, get_user_trades.
5//!
6//! All tests in this suite require authentication. The core test is a
7//! place-then-cancel roundtrip that never fills (limit buy far below market).
8
9use std::time::Instant;
10
11use crate::core::traits::{ExchangeIdentity, MarketData, Trading};
12use crate::core::types::{
13    AccountType, CancelRequest, CancelScope, OrderHistoryFilter, OrderRequest,
14    OrderSide, OrderType, PlaceOrderResponse, Symbol, SymbolInput, TimeInForce, UserTradeFilter,
15};
16
17use super::{is_auth_error, is_unsupported, TestResult};
18
19// ═══════════════════════════════════════════════════════════════════════════════
20// RUN ALL
21// ═══════════════════════════════════════════════════════════════════════════════
22
23/// Run all trading suite tests against `connector`.
24///
25/// `symbol` — the trading pair to use (e.g. `Symbol::new("BTC", "USDT")`).
26/// `account_type` — which account type to trade on.
27///
28/// Returns one `TestResult` per test function.
29pub async fn run_all(
30    connector: &(dyn TradingWithMarketData + Send + Sync),
31    symbol: Symbol,
32    account_type: AccountType,
33) -> Vec<TestResult> {
34    let mut results = Vec::new();
35
36    results.push(
37        test_place_cancel_roundtrip(connector, symbol.clone(), account_type).await,
38    );
39    results.push(test_get_open_orders(connector, symbol.clone(), account_type).await);
40    results.push(test_get_order_history(connector, symbol.clone(), account_type).await);
41    results.push(test_get_user_trades(connector, symbol.clone(), account_type).await);
42
43    results
44}
45
46// ═══════════════════════════════════════════════════════════════════════════════
47// HELPER SUPERTRAIT
48// ═══════════════════════════════════════════════════════════════════════════════
49
50/// Combined supertrait required by all trading tests.
51///
52/// A connector must implement `Trading + MarketData + ExchangeIdentity`
53/// to run this suite. Using a combined trait object avoids the need for
54/// generic parameters in the `run_all` entry point.
55pub trait TradingWithMarketData: Trading + MarketData + ExchangeIdentity {}
56
57impl<T: Trading + MarketData + ExchangeIdentity> TradingWithMarketData for T {}
58
59// ═══════════════════════════════════════════════════════════════════════════════
60// TEST: place_cancel_roundtrip
61// ═══════════════════════════════════════════════════════════════════════════════
62
63/// Place a far-below-market limit BUY, verify it exists, then cancel it.
64///
65/// The order is placed at 30% of the current market price to guarantee it
66/// will never fill during the test.
67///
68/// Steps:
69/// 1. Fetch current price via `MarketData::get_price`.
70/// 2. Place LIMIT BUY at `price * 0.3` with quantity 0.001.
71/// 3. Verify order exists via `get_order`.
72/// 4. Cancel via `cancel_order`.
73///
74/// If any step returns `UnsupportedOperation` the test is `Skipped`.
75/// If `place_order` fails the test is `Error` — no cancel is attempted.
76/// If cancel fails the test is `Error` and the order ID is included in the
77/// message for manual cleanup.
78pub async fn test_place_cancel_roundtrip(
79    connector: &(dyn TradingWithMarketData + Send + Sync),
80    symbol: Symbol,
81    account_type: AccountType,
82) -> TestResult {
83    const NAME: &str = "test_place_cancel_roundtrip";
84    let exchange = connector.exchange_name();
85    let start = Instant::now();
86
87    // ── Step 1: get current price ────────────────────────────────────────────
88    let raw_sym = symbol.to_concat();
89    let price = match connector.get_price(SymbolInput::Raw(&raw_sym), account_type).await {
90        Ok(p) => p,
91        Err(err) if is_unsupported(&err) => {
92            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
93                format!("get_price unsupported: {err}"));
94        }
95        Err(err) if is_auth_error(&err) => {
96            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
97                format!("auth error fetching price: {err}"));
98        }
99        Err(err) => {
100            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
101                format!("failed to get price: {err}"));
102        }
103    };
104
105    let far_price = (price * 0.3 * 100.0).round() / 100.0;
106
107    // ── Step 2: place far-below-market limit buy ─────────────────────────────
108    let req = OrderRequest {
109        symbol: symbol.clone(),
110        side: OrderSide::Buy,
111        order_type: OrderType::Limit { price: far_price },
112        quantity: 0.001,
113        time_in_force: TimeInForce::Gtc,
114        account_type,
115        client_order_id: None,
116        reduce_only: false,
117    };
118
119    let place_resp = match connector.place_order(req).await {
120        Ok(r) => r,
121        Err(err) if is_unsupported(&err) => {
122            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
123                format!("place_order unsupported: {err}"));
124        }
125        Err(err) if is_auth_error(&err) => {
126            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
127                format!("auth error placing order: {err}"));
128        }
129        Err(err) => {
130            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
131                format!("place_order failed: {err}"));
132        }
133    };
134
135    // Extract the order ID from the response.
136    let order_id = match &place_resp {
137        PlaceOrderResponse::Simple(order) => order.id.clone(),
138        PlaceOrderResponse::Bracket(br) => br.entry_order.id.clone(),
139        PlaceOrderResponse::Oco(oco) => oco.first_order.id.clone(),
140        PlaceOrderResponse::Algo(algo) => algo.algo_id.clone(),
141    };
142
143    // ── Step 3: verify order exists via get_order ────────────────────────────
144    match connector.get_order(
145        &symbol.to_concat(),
146        &order_id,
147        account_type,
148    ).await {
149        Ok(_) => {} // order confirmed
150        Err(err) if is_unsupported(&err) => {
151            // get_order not supported — still try to cancel
152        }
153        Err(err) => {
154            // get_order failed but order was placed; attempt cancel and report error
155            let _ = cancel_single(connector, &symbol, &order_id, account_type).await;
156            return TestResult::error(
157                NAME, exchange,
158                start.elapsed().as_millis() as u64,
159                format!("get_order failed for id={order_id}: {err}"),
160            );
161        }
162    }
163
164    // ── Step 4: cancel the order ─────────────────────────────────────────────
165    match cancel_single(connector, &symbol, &order_id, account_type).await {
166        Ok(_) => {
167            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
168        }
169        Err(err) if is_unsupported(&err) => {
170            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
171                format!("cancel_order unsupported: {err}"))
172        }
173        Err(err) => {
174            TestResult::error(
175                NAME, exchange,
176                start.elapsed().as_millis() as u64,
177                format!(
178                    "cancel failed — MANUAL CLEANUP REQUIRED order_id={order_id}: {err}"
179                ),
180            )
181        }
182    }
183}
184
185/// Send a `CancelScope::Single` request for `order_id` on `symbol`.
186async fn cancel_single(
187    connector: &dyn TradingWithMarketData,
188    symbol: &Symbol,
189    order_id: &str,
190    account_type: AccountType,
191) -> Result<(), crate::core::types::ExchangeError> {
192    let cancel_req = CancelRequest {
193        scope: CancelScope::Single {
194            order_id: order_id.to_string(),
195        },
196        symbol: Some(symbol.clone()),
197        account_type,
198    };
199    connector.cancel_order(cancel_req).await.map(|_| ())
200}
201
202// ═══════════════════════════════════════════════════════════════════════════════
203// TEST: get_open_orders
204// ═══════════════════════════════════════════════════════════════════════════════
205
206/// Fetch open orders for `symbol` and verify the call succeeds.
207///
208/// The result may be an empty list — that is valid. What matters is that
209/// the connector returns `Ok` (or `UnsupportedOperation → Skip`).
210pub async fn test_get_open_orders(
211    connector: &(dyn TradingWithMarketData + Send + Sync),
212    symbol: Symbol,
213    account_type: AccountType,
214) -> TestResult {
215    const NAME: &str = "test_get_open_orders";
216    let exchange = connector.exchange_name();
217    let start = Instant::now();
218
219    match connector
220        .get_open_orders(Some(&symbol.to_concat()), account_type)
221        .await
222    {
223        Ok(_orders) => TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64),
224        Err(err) if is_unsupported(&err) => {
225            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
226                format!("get_open_orders unsupported: {err}"))
227        }
228        Err(err) if is_auth_error(&err) => {
229            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
230                format!("auth error: {err}"))
231        }
232        Err(err) => {
233            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
234                format!("get_open_orders failed: {err}"))
235        }
236    }
237}
238
239// ═══════════════════════════════════════════════════════════════════════════════
240// TEST: get_order_history
241// ═══════════════════════════════════════════════════════════════════════════════
242
243/// Fetch recent order history for `symbol` and verify the call succeeds.
244///
245/// Uses a minimal filter (symbol only, no time bounds, limit=10).
246/// An empty result is valid.
247pub async fn test_get_order_history(
248    connector: &(dyn TradingWithMarketData + Send + Sync),
249    symbol: Symbol,
250    account_type: AccountType,
251) -> TestResult {
252    const NAME: &str = "test_get_order_history";
253    let exchange = connector.exchange_name();
254    let start = Instant::now();
255
256    let filter = OrderHistoryFilter {
257        symbol: Some(symbol.clone()),
258        start_time: None,
259        end_time: None,
260        limit: Some(10),
261        status: None,
262    };
263
264    match connector.get_order_history(filter, account_type).await {
265        Ok(_orders) => TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64),
266        Err(err) if is_unsupported(&err) => {
267            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
268                format!("get_order_history unsupported: {err}"))
269        }
270        Err(err) if is_auth_error(&err) => {
271            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
272                format!("auth error: {err}"))
273        }
274        Err(err) => {
275            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
276                format!("get_order_history failed: {err}"))
277        }
278    }
279}
280
281// ═══════════════════════════════════════════════════════════════════════════════
282// TEST: get_user_trades
283// ═══════════════════════════════════════════════════════════════════════════════
284
285/// Fetch recent user trade fills for `symbol` and verify the call succeeds.
286///
287/// Many DEX connectors return `UnsupportedOperation` here — that results
288/// in a `Skipped` status rather than a failure.
289pub async fn test_get_user_trades(
290    connector: &(dyn TradingWithMarketData + Send + Sync),
291    symbol: Symbol,
292    account_type: AccountType,
293) -> TestResult {
294    const NAME: &str = "test_get_user_trades";
295    let exchange = connector.exchange_name();
296    let start = Instant::now();
297
298    let filter = UserTradeFilter {
299        symbol: Some(symbol.to_concat()),
300        order_id: None,
301        start_time: None,
302        end_time: None,
303        limit: Some(10),
304    };
305
306    match connector.get_user_trades(filter, account_type).await {
307        Ok(_trades) => TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64),
308        Err(err) if is_unsupported(&err) => {
309            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
310                format!("get_user_trades unsupported: {err}"))
311        }
312        Err(err) if is_auth_error(&err) => {
313            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
314                format!("auth error: {err}"))
315        }
316        Err(err) => {
317            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
318                format!("get_user_trades failed: {err}"))
319        }
320    }
321}