1use 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
26pub trait AmendConnector: AmendOrder + Trading + MarketData + ExchangeIdentity {}
32impl<T: AmendOrder + Trading + MarketData + ExchangeIdentity> AmendConnector for T {}
33
34pub trait CancelAllConnector: CancelAll + Trading + MarketData + ExchangeIdentity {}
36impl<T: CancelAll + Trading + MarketData + ExchangeIdentity> CancelAllConnector for T {}
37
38pub trait BatchConnector: BatchOrders + Trading + MarketData + ExchangeIdentity {}
40impl<T: BatchOrders + Trading + MarketData + ExchangeIdentity> BatchConnector for T {}
41
42pub 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 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 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 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 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 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 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 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
173pub 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 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 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 let order_b_id = match place_limit_buy(connector, symbol.clone(), price_b, account_type).await {
228 Ok(id) => id,
229 Err(err) => {
230 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 let cancel_scope = CancelScope::BySymbol { symbol: symbol.clone() };
239 match connector.cancel_all_orders(cancel_scope, account_type).await {
240 Ok(_) => {} Err(err) if is_unsupported(&err) => {
242 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 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 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
288pub 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 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 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 let mut placed_ids: Vec<String> = Vec::new();
369 for (i, result) in results.iter().enumerate() {
370 if !result.success {
371 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 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 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 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
446fn 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
460async 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
484async 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}