Skip to main content

digdigdig3/testing/suites/
operations.rs

1//! # Operations Suite
2//!
3//! Tests for optional operation traits: `AmendOrder`, `CancelAll`, `BatchOrders`.
4//!
5//! Each test is independently skippable — if the connector does not implement
6//! the relevant trait, the test returns `Skipped` via `UnsupportedOperation`.
7//!
8//! Unlike the trading suite, these functions accept concrete combined trait
9//! objects rather than a single supertrait so the harness can choose which
10//! tests to run based on declared feature flags.
11
12use std::time::Instant;
13
14use crate::core::traits::{
15    AmendOrder, BatchOrders, CancelAll,
16    ExchangeIdentity, MarketData, Trading,
17};
18use crate::core::types::{
19    AccountType, AmendFields, AmendRequest, CancelRequest, CancelScope,
20    OrderRequest, OrderSide, OrderType, PlaceOrderResponse, Symbol,
21    SymbolInput, TimeInForce,
22};
23
24use super::{is_auth_error, is_unsupported, TestResult};
25
26// ═══════════════════════════════════════════════════════════════════════════════
27// COMBINED TRAIT OBJECTS
28// ═══════════════════════════════════════════════════════════════════════════════
29
30/// Trait object required by `test_amend_order`.
31pub trait AmendConnector: AmendOrder + Trading + MarketData + ExchangeIdentity {}
32impl<T: AmendOrder + Trading + MarketData + ExchangeIdentity> AmendConnector for T {}
33
34/// Trait object required by `test_cancel_all`.
35pub trait CancelAllConnector: CancelAll + Trading + MarketData + ExchangeIdentity {}
36impl<T: CancelAll + Trading + MarketData + ExchangeIdentity> CancelAllConnector for T {}
37
38/// Trait object required by `test_batch_orders`.
39pub trait BatchConnector: BatchOrders + Trading + MarketData + ExchangeIdentity {}
40impl<T: BatchOrders + Trading + MarketData + ExchangeIdentity> BatchConnector for T {}
41
42// ═══════════════════════════════════════════════════════════════════════════════
43// TEST: amend_order
44// ═══════════════════════════════════════════════════════════════════════════════
45
46/// Place a far-below-market limit BUY, amend its price even further down,
47/// verify the new price, then cancel.
48///
49/// Steps:
50/// 1. Fetch current price.
51/// 2. Place LIMIT BUY at `price * 0.3`.
52/// 3. Amend the order to `price * 0.25`.
53/// 4. Verify the amended order has the new price via `get_order`.
54/// 5. Cancel the order.
55///
56/// Any `UnsupportedOperation` in any step → `Skipped`.
57pub async fn test_amend_order(
58    connector: &(dyn AmendConnector + Send + Sync),
59    symbol: Symbol,
60    account_type: AccountType,
61) -> TestResult {
62    const NAME: &str = "test_amend_order";
63    let exchange = connector.exchange_name();
64    let start = Instant::now();
65
66    // ── Step 1: current price ────────────────────────────────────────────────
67    let raw_sym = symbol.to_concat();
68    let price = match connector.get_price(SymbolInput::Raw(&raw_sym), account_type).await {
69        Ok(p) => p,
70        Err(err) if is_unsupported(&err) => {
71            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
72                format!("get_price unsupported: {err}"));
73        }
74        Err(err) => {
75            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
76                format!("failed to get price: {err}"));
77        }
78    };
79
80    let original_price = (price * 0.3 * 100.0).round() / 100.0;
81    let amended_price  = (price * 0.25 * 100.0).round() / 100.0;
82
83    // ── Step 2: place order ──────────────────────────────────────────────────
84    let req = OrderRequest {
85        symbol: symbol.clone(),
86        side: OrderSide::Buy,
87        order_type: OrderType::Limit { price: original_price },
88        quantity: 0.001,
89        time_in_force: TimeInForce::Gtc,
90        account_type,
91        client_order_id: None,
92        reduce_only: false,
93    };
94
95    let place_resp = match connector.place_order(req).await {
96        Ok(r) => r,
97        Err(err) if is_unsupported(&err) => {
98            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
99                format!("place_order unsupported: {err}"));
100        }
101        Err(err) if is_auth_error(&err) => {
102            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
103                format!("auth error placing order: {err}"));
104        }
105        Err(err) => {
106            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
107                format!("place_order failed: {err}"));
108        }
109    };
110
111    let order_id = extract_order_id(&place_resp);
112
113    // ── Step 3: amend price ──────────────────────────────────────────────────
114    let amend_req = AmendRequest {
115        order_id: order_id.clone(),
116        symbol: symbol.clone(),
117        account_type,
118        fields: AmendFields {
119            price: Some(amended_price),
120            quantity: None,
121            trigger_price: None,
122        },
123    };
124
125    match connector.amend_order(amend_req).await {
126        Ok(amended_order) => {
127            // ── Step 4: verify new price ─────────────────────────────────────
128            let actual_price = amended_order.price.unwrap_or(0.0);
129            let price_matches = (actual_price - amended_price).abs() < 0.001 * amended_price;
130
131            // Always attempt cancel before returning.
132            let cancel_result = cancel_single_trading(
133                connector, &symbol, &order_id, account_type,
134            ).await;
135
136            if !price_matches {
137                return TestResult::fail(
138                    NAME, exchange,
139                    start.elapsed().as_millis() as u64,
140                    format!(
141                        "amended price mismatch: expected ~{amended_price}, got {actual_price}"
142                    ),
143                );
144            }
145
146            match cancel_result {
147                Ok(_) => TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64),
148                Err(err) => TestResult::error(
149                    NAME, exchange,
150                    start.elapsed().as_millis() as u64,
151                    format!("cancel failed after amend — MANUAL CLEANUP order_id={order_id}: {err}"),
152                ),
153            }
154        }
155        Err(err) if is_unsupported(&err) => {
156            // amend not supported — cancel the order we placed and skip.
157            let _ = cancel_single_trading(connector, &symbol, &order_id, account_type).await;
158            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
159                format!("amend_order unsupported: {err}"))
160        }
161        Err(err) => {
162            // Amend failed — attempt cancel and report error.
163            let _ = cancel_single_trading(connector, &symbol, &order_id, account_type).await;
164            TestResult::error(
165                NAME, exchange,
166                start.elapsed().as_millis() as u64,
167                format!("amend_order failed for id={order_id}: {err}"),
168            )
169        }
170    }
171}
172
173// ═══════════════════════════════════════════════════════════════════════════════
174// TEST: cancel_all
175// ═══════════════════════════════════════════════════════════════════════════════
176
177/// Place 2 far-below-market limit BUYs, cancel all for the symbol, verify both gone.
178///
179/// Steps:
180/// 1. Fetch current price.
181/// 2. Place order A at `price * 0.3`.
182/// 3. Place order B at `price * 0.29`.
183/// 4. `cancel_all_orders(CancelScope::BySymbol { symbol })`.
184/// 5. `get_open_orders(Some(symbol))` — verify result is empty or neither A/B present.
185///
186/// Any `UnsupportedOperation` → `Skipped`.
187pub async fn test_cancel_all(
188    connector: &(dyn CancelAllConnector + Send + Sync),
189    symbol: Symbol,
190    account_type: AccountType,
191) -> TestResult {
192    const NAME: &str = "test_cancel_all";
193    let exchange = connector.exchange_name();
194    let start = Instant::now();
195
196    // ── Step 1: current price ────────────────────────────────────────────────
197    let raw_sym = symbol.to_concat();
198    let price = match connector.get_price(SymbolInput::Raw(&raw_sym), account_type).await {
199        Ok(p) => p,
200        Err(err) if is_unsupported(&err) => {
201            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
202                format!("get_price unsupported: {err}"));
203        }
204        Err(err) => {
205            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
206                format!("failed to get price: {err}"));
207        }
208    };
209
210    let price_a = (price * 0.3 * 100.0).round() / 100.0;
211    let price_b = (price * 0.29 * 100.0).round() / 100.0;
212
213    // ── Step 2: place order A ────────────────────────────────────────────────
214    let order_a_id = match place_limit_buy(connector, symbol.clone(), price_a, account_type).await {
215        Ok(id) => id,
216        Err(err) if is_unsupported(&err) => {
217            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
218                format!("place_order unsupported: {err}"));
219        }
220        Err(err) => {
221            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
222                format!("place_order (A) failed: {err}"));
223        }
224    };
225
226    // ── Step 3: place order B ────────────────────────────────────────────────
227    let order_b_id = match place_limit_buy(connector, symbol.clone(), price_b, account_type).await {
228        Ok(id) => id,
229        Err(err) => {
230            // Cancel A before bailing out.
231            let _ = cancel_single_trading(connector, &symbol, &order_a_id, account_type).await;
232            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
233                format!("place_order (B) failed: {err}"));
234        }
235    };
236
237    // ── Step 4: cancel all for symbol ────────────────────────────────────────
238    let cancel_scope = CancelScope::BySymbol { symbol: symbol.clone() };
239    match connector.cancel_all_orders(cancel_scope, account_type).await {
240        Ok(_) => {} // proceed to verification
241        Err(err) if is_unsupported(&err) => {
242            // Cleanup both orders manually then skip.
243            let _ = cancel_single_trading(connector, &symbol, &order_a_id, account_type).await;
244            let _ = cancel_single_trading(connector, &symbol, &order_b_id, account_type).await;
245            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
246                format!("cancel_all_orders unsupported: {err}"));
247        }
248        Err(err) => {
249            let _ = cancel_single_trading(connector, &symbol, &order_a_id, account_type).await;
250            let _ = cancel_single_trading(connector, &symbol, &order_b_id, account_type).await;
251            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
252                format!("cancel_all_orders failed: {err}"));
253        }
254    }
255
256    // ── Step 5: verify both orders are gone ──────────────────────────────────
257    match connector.get_open_orders(Some(&symbol.to_concat()), account_type).await {
258        Ok(open_orders) => {
259            let ids: Vec<&str> = open_orders.iter().map(|o| o.id.as_str()).collect();
260            let a_still_open = ids.contains(&order_a_id.as_str());
261            let b_still_open = ids.contains(&order_b_id.as_str());
262
263            if a_still_open || b_still_open {
264                TestResult::fail(
265                    NAME, exchange,
266                    start.elapsed().as_millis() as u64,
267                    format!(
268                        "orders still open after cancel_all: \
269                         A={} ({a_still_open}), B={} ({b_still_open})",
270                        order_a_id, order_b_id
271                    ),
272                )
273            } else {
274                TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
275            }
276        }
277        Err(err) if is_unsupported(&err) => {
278            // get_open_orders not supported — treat cancel_all as pass since it returned Ok.
279            TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
280        }
281        Err(err) => {
282            TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
283                format!("get_open_orders verification failed: {err}"))
284        }
285    }
286}
287
288// ═══════════════════════════════════════════════════════════════════════════════
289// TEST: batch_orders
290// ═══════════════════════════════════════════════════════════════════════════════
291
292/// Place 2 orders in a batch, verify both exist, then cancel both.
293///
294/// Steps:
295/// 1. Fetch current price.
296/// 2. Build a 2-order batch (LIMIT BUY at `price * 0.3` and `price * 0.29`).
297/// 3. `place_orders_batch(orders)` — verify both `OrderResult.success == true`.
298/// 4. `cancel_orders_batch([id_a, id_b])`.
299///
300/// Any `UnsupportedOperation` → `Skipped`.
301pub async fn test_batch_orders(
302    connector: &(dyn BatchConnector + Send + Sync),
303    symbol: Symbol,
304    account_type: AccountType,
305) -> TestResult {
306    const NAME: &str = "test_batch_orders";
307    let exchange = connector.exchange_name();
308    let start = Instant::now();
309
310    // ── Step 1: current price ────────────────────────────────────────────────
311    let raw_sym = symbol.to_concat();
312    let price = match connector.get_price(SymbolInput::Raw(&raw_sym), account_type).await {
313        Ok(p) => p,
314        Err(err) if is_unsupported(&err) => {
315            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
316                format!("get_price unsupported: {err}"));
317        }
318        Err(err) => {
319            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
320                format!("failed to get price: {err}"));
321        }
322    };
323
324    let price_a = (price * 0.3 * 100.0).round() / 100.0;
325    let price_b = (price * 0.29 * 100.0).round() / 100.0;
326
327    let orders = vec![
328        OrderRequest {
329            symbol: symbol.clone(),
330            side: OrderSide::Buy,
331            order_type: OrderType::Limit { price: price_a },
332            quantity: 0.001,
333            time_in_force: TimeInForce::Gtc,
334            account_type,
335            client_order_id: Some("test_batch_a".to_string()),
336            reduce_only: false,
337        },
338        OrderRequest {
339            symbol: symbol.clone(),
340            side: OrderSide::Buy,
341            order_type: OrderType::Limit { price: price_b },
342            quantity: 0.001,
343            time_in_force: TimeInForce::Gtc,
344            account_type,
345            client_order_id: Some("test_batch_b".to_string()),
346            reduce_only: false,
347        },
348    ];
349
350    // ── Step 3: place batch ──────────────────────────────────────────────────
351    let results = match connector.place_orders_batch(orders).await {
352        Ok(r) => r,
353        Err(err) if is_unsupported(&err) => {
354            return TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
355                format!("place_orders_batch unsupported: {err}"));
356        }
357        Err(err) if is_auth_error(&err) => {
358            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
359                format!("auth error: {err}"));
360        }
361        Err(err) => {
362            return TestResult::error(NAME, exchange, start.elapsed().as_millis() as u64,
363                format!("place_orders_batch failed: {err}"));
364        }
365    };
366
367    // Check both orders succeeded and collect their IDs.
368    let mut placed_ids: Vec<String> = Vec::new();
369    for (i, result) in results.iter().enumerate() {
370        if !result.success {
371            // Attempt to cancel any that did succeed before returning.
372            if !placed_ids.is_empty() {
373                let _ = connector
374                    .cancel_orders_batch(placed_ids.clone(), Some(&symbol.to_concat()), account_type)
375                    .await;
376            }
377            let err_msg = result.error.clone().unwrap_or_else(|| "unknown error".to_string());
378            return TestResult::fail(
379                NAME, exchange,
380                start.elapsed().as_millis() as u64,
381                format!("batch order [{i}] failed: {err_msg}"),
382            );
383        }
384        if let Some(ref order) = result.order {
385            placed_ids.push(order.id.clone());
386        }
387    }
388
389    if placed_ids.len() != 2 {
390        // Something unexpected — log but continue to cancel.
391        let _ = connector
392            .cancel_orders_batch(placed_ids.clone(), Some(&symbol.to_concat()), account_type)
393            .await;
394        return TestResult::fail(
395            NAME, exchange,
396            start.elapsed().as_millis() as u64,
397            format!("expected 2 placed order IDs, got {}", placed_ids.len()),
398        );
399    }
400
401    // ── Step 4: cancel batch ─────────────────────────────────────────────────
402    match connector
403        .cancel_orders_batch(placed_ids.clone(), Some(&symbol.to_concat()), account_type)
404        .await
405    {
406        Ok(cancel_results) => {
407            let failed: Vec<&str> = cancel_results
408                .iter()
409                .filter(|r| !r.success)
410                .filter_map(|r| r.error.as_deref())
411                .collect();
412            if failed.is_empty() {
413                TestResult::pass(NAME, exchange, start.elapsed().as_millis() as u64)
414            } else {
415                TestResult::error(
416                    NAME, exchange,
417                    start.elapsed().as_millis() as u64,
418                    format!(
419                        "some batch cancels failed — MANUAL CLEANUP ids={:?}: {:?}",
420                        placed_ids, failed
421                    ),
422                )
423            }
424        }
425        Err(err) if is_unsupported(&err) => {
426            // Batch cancel not supported — fall back to single cancels.
427            for id in &placed_ids {
428                let _ = cancel_single_trading(connector, &symbol, id, account_type).await;
429            }
430            TestResult::skip(NAME, exchange, start.elapsed().as_millis() as u64,
431                format!("cancel_orders_batch unsupported: {err}"))
432        }
433        Err(err) => {
434            TestResult::error(
435                NAME, exchange,
436                start.elapsed().as_millis() as u64,
437                format!(
438                    "cancel_orders_batch failed — MANUAL CLEANUP ids={:?}: {err}",
439                    placed_ids
440                ),
441            )
442        }
443    }
444}
445
446// ═══════════════════════════════════════════════════════════════════════════════
447// HELPERS
448// ═══════════════════════════════════════════════════════════════════════════════
449
450/// Extract the primary order ID from any `PlaceOrderResponse` variant.
451fn extract_order_id(resp: &PlaceOrderResponse) -> String {
452    match resp {
453        PlaceOrderResponse::Simple(order) => order.id.clone(),
454        PlaceOrderResponse::Bracket(br) => br.entry_order.id.clone(),
455        PlaceOrderResponse::Oco(oco) => oco.first_order.id.clone(),
456        PlaceOrderResponse::Algo(algo) => algo.algo_id.clone(),
457    }
458}
459
460/// Place a single GTC limit BUY and return its order ID.
461async fn place_limit_buy<C>(
462    connector: &C,
463    symbol: Symbol,
464    price: f64,
465    account_type: AccountType,
466) -> Result<String, crate::core::types::ExchangeError>
467where
468    C: Trading + MarketData + ExchangeIdentity + ?Sized,
469{
470    let req = OrderRequest {
471        symbol,
472        side: OrderSide::Buy,
473        order_type: OrderType::Limit { price },
474        quantity: 0.001,
475        time_in_force: TimeInForce::Gtc,
476        account_type,
477        client_order_id: None,
478        reduce_only: false,
479    };
480    let resp = connector.place_order(req).await?;
481    Ok(extract_order_id(&resp))
482}
483
484/// Cancel a single order by ID.
485async fn cancel_single_trading<C>(
486    connector: &C,
487    symbol: &Symbol,
488    order_id: &str,
489    account_type: AccountType,
490) -> Result<(), crate::core::types::ExchangeError>
491where
492    C: Trading + ?Sized,
493{
494    let cancel_req = CancelRequest {
495        scope: CancelScope::Single {
496            order_id: order_id.to_string(),
497        },
498        symbol: Some(symbol.clone()),
499        account_type,
500    };
501    connector.cancel_order(cancel_req).await.map(|_| ())
502}