rusty_ems/error/
batch_errors.rs

1//! Batch order error handling with separation of transport-level and per-order errors
2//!
3//! This module provides specialized error types for batch operations that distinguish between:
4//! - Transport-level errors: Network, authentication, rate limiting - affect the entire batch
5//! - Per-order errors: Invalid symbols, insufficient balance - affect individual orders only
6//!
7//! This separation allows for better error handling strategies, more accurate reporting,
8//! and improved debugging capabilities.
9
10use crate::error::EMSError;
11use rusty_common::SmartString;
12use rusty_common::collections::FxHashMap;
13use rusty_model::trading_order::Order;
14use smallvec::SmallVec;
15use thiserror::Error;
16
17/// Errors specific to batch order operations
18#[derive(Error, Debug, Clone)]
19pub enum BatchOrderError {
20    /// Transport-level error that affects the entire batch
21    /// These errors should trigger retry or fallback strategies
22    #[error("Transport error affecting entire batch: {error}")]
23    TransportError {
24        /// The underlying transport error
25        error: EMSError,
26        /// Number of orders that were affected
27        affected_orders: usize,
28        /// Whether this error suggests a retry might succeed
29        is_retryable: bool,
30    },
31
32    /// Per-order errors where some orders succeed and others fail
33    /// The batch operation partially succeeded
34    #[error("Partial batch failure: {successful_orders} succeeded, {failed_orders} failed")]
35    PartialFailure {
36        /// Number of orders that succeeded
37        successful_orders: usize,
38        /// Number of orders that failed
39        failed_orders: usize,
40        /// Detailed results for each order
41        order_results: OrderResultMap,
42    },
43
44    /// All orders in the batch failed due to individual order issues
45    /// No transport-level problems, but all orders had validation/business logic errors
46    #[error("All orders failed validation: {total_orders} orders")]
47    AllOrdersFailed {
48        /// Total number of orders in the batch
49        total_orders: usize,
50        /// Detailed results for each order
51        order_results: OrderResultMap,
52    },
53
54    /// Empty batch provided
55    #[error("Empty batch: no orders to process")]
56    EmptyBatch,
57
58    /// Batch size exceeds exchange limits
59    #[error("Batch size {actual} exceeds maximum limit {max_allowed}")]
60    BatchSizeExceeded {
61        /// Actual number of orders in the batch
62        actual: usize,
63        /// Maximum number of orders allowed by the exchange
64        max_allowed: usize,
65    },
66}
67
68/// Result for an individual order within a batch operation
69#[derive(Debug, Clone)]
70pub enum OrderResult<T> {
71    /// Order processed successfully
72    Success(T),
73    /// Order failed with specific error
74    Failed {
75        /// The error that caused the failure
76        error: EMSError,
77        /// The original order that failed
78        order: Box<Order>,
79        /// Whether this error might succeed on retry
80        is_retryable: bool,
81    },
82}
83
84/// Map of order results indexed by order ID or client order ID
85pub type OrderResultMap<T = ()> = FxHashMap<SmartString, OrderResult<T>>;
86
87/// Comprehensive result for batch operations
88#[derive(Debug, Clone)]
89pub struct BatchResult<T> {
90    /// Overall status of the batch operation
91    pub status: BatchStatus,
92    /// Individual results for each order
93    pub order_results: OrderResultMap<T>,
94    /// Summary statistics
95    pub summary: BatchSummary,
96    /// Optional transport-level error that affected the entire batch
97    pub transport_error: Option<EMSError>,
98}
99
100/// Status of a batch operation
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum BatchStatus {
103    /// All orders succeeded
104    AllSucceeded,
105    /// Some orders succeeded, some failed (partial success)
106    PartialSuccess,
107    /// All orders failed due to individual validation errors
108    AllFailed,
109    /// Entire batch failed due to transport-level error
110    TransportFailure,
111}
112
113/// Summary statistics for a batch operation
114#[derive(Debug, Clone, Default)]
115pub struct BatchSummary {
116    /// Total number of orders in the batch
117    pub total_orders: usize,
118    /// Number of orders that succeeded
119    pub successful_orders: usize,
120    /// Number of orders that failed
121    pub failed_orders: usize,
122    /// Number of orders that could be retried
123    pub retryable_orders: usize,
124    /// Processing time in nanoseconds
125    pub processing_time_ns: u64,
126}
127
128impl<T> BatchResult<T> {
129    /// Create a new successful batch result
130    #[must_use]
131    pub fn success(order_results: OrderResultMap<T>, processing_time_ns: u64) -> Self {
132        let total_orders = order_results.len();
133        Self {
134            status: BatchStatus::AllSucceeded,
135            order_results,
136            summary: BatchSummary {
137                total_orders,
138                successful_orders: total_orders,
139                failed_orders: 0,
140                retryable_orders: 0,
141                processing_time_ns,
142            },
143            transport_error: None,
144        }
145    }
146
147    /// Create a new partial success result
148    #[must_use]
149    pub fn partial_success(order_results: OrderResultMap<T>, processing_time_ns: u64) -> Self {
150        let total_orders = order_results.len();
151        let successful_orders = order_results
152            .values()
153            .filter(|result| matches!(result, OrderResult::Success(_)))
154            .count();
155        let failed_orders = total_orders - successful_orders;
156        let retryable_orders = order_results
157            .values()
158            .filter(|result| {
159                matches!(
160                    result,
161                    OrderResult::Failed {
162                        is_retryable: true,
163                        ..
164                    }
165                )
166            })
167            .count();
168
169        Self {
170            status: BatchStatus::PartialSuccess,
171            order_results,
172            summary: BatchSummary {
173                total_orders,
174                successful_orders,
175                failed_orders,
176                retryable_orders,
177                processing_time_ns,
178            },
179            transport_error: None,
180        }
181    }
182
183    /// Create a new all-failed result (due to per-order errors)
184    #[must_use]
185    pub fn all_failed(order_results: OrderResultMap<T>, processing_time_ns: u64) -> Self {
186        let total_orders = order_results.len();
187        let retryable_orders = order_results
188            .values()
189            .filter(|result| {
190                matches!(
191                    result,
192                    OrderResult::Failed {
193                        is_retryable: true,
194                        ..
195                    }
196                )
197            })
198            .count();
199
200        Self {
201            status: BatchStatus::AllFailed,
202            order_results,
203            summary: BatchSummary {
204                total_orders,
205                successful_orders: 0,
206                failed_orders: total_orders,
207                retryable_orders,
208                processing_time_ns,
209            },
210            transport_error: None,
211        }
212    }
213
214    /// Create a new transport failure result
215    #[must_use]
216    pub fn transport_failure(
217        error: EMSError,
218        total_orders: usize,
219        processing_time_ns: u64,
220    ) -> Self {
221        Self {
222            status: BatchStatus::TransportFailure,
223            order_results: FxHashMap::default(),
224            summary: BatchSummary {
225                total_orders,
226                successful_orders: 0,
227                failed_orders: total_orders,
228                retryable_orders: if error.is_recoverable() {
229                    total_orders
230                } else {
231                    0
232                },
233                processing_time_ns,
234            },
235            transport_error: Some(error),
236        }
237    }
238
239    /// Check if any orders succeeded
240    #[must_use]
241    pub const fn has_successes(&self) -> bool {
242        self.summary.successful_orders > 0
243    }
244
245    /// Check if any orders failed
246    #[must_use]
247    pub const fn has_failures(&self) -> bool {
248        self.summary.failed_orders > 0
249    }
250
251    /// Check if any orders can be retried
252    #[must_use]
253    pub const fn has_retryable_orders(&self) -> bool {
254        self.summary.retryable_orders > 0
255    }
256
257    /// Get the success rate as a percentage
258    #[must_use]
259    pub fn success_rate(&self) -> f64 {
260        if self.summary.total_orders == 0 {
261            0.0
262        } else {
263            (self.summary.successful_orders as f64 / self.summary.total_orders as f64) * 100.0
264        }
265    }
266
267    /// Get orders that can be retried
268    #[must_use]
269    pub fn get_retryable_orders(&self) -> SmallVec<[Order; 8]> {
270        self.order_results
271            .values()
272            .filter_map(|result| {
273                if let OrderResult::Failed {
274                    order,
275                    is_retryable: true,
276                    ..
277                } = result
278                {
279                    Some((**order).clone())
280                } else {
281                    None
282                }
283            })
284            .collect()
285    }
286
287    /// Get failed orders grouped by error type for better error reporting
288    #[must_use]
289    pub fn get_failures_by_error_type(&self) -> FxHashMap<String, SmallVec<[Order; 4]>> {
290        let mut failures: FxHashMap<String, SmallVec<[Order; 4]>> = FxHashMap::default();
291
292        for result in self.order_results.values() {
293            if let OrderResult::Failed { error, order, .. } = result {
294                let error_type = match error {
295                    EMSError::InvalidOrderParameters(_) => "Invalid Parameters",
296                    EMSError::InsufficientBalance(_) => "Insufficient Balance",
297                    EMSError::InstrumentNotFound(_) => "Instrument Not Found",
298                    EMSError::RateLimitExceeded { .. } => "Rate Limit",
299                    _ => "Other",
300                };
301                failures
302                    .entry(error_type.to_string())
303                    .or_default()
304                    .push((**order).clone());
305            }
306        }
307
308        failures
309    }
310}
311
312impl BatchOrderError {
313    /// Create a transport error that affects the entire batch
314    #[must_use]
315    pub const fn transport(error: EMSError, affected_orders: usize) -> Self {
316        let is_retryable = error.is_recoverable();
317        Self::TransportError {
318            error,
319            affected_orders,
320            is_retryable,
321        }
322    }
323
324    /// Create a partial failure error
325    #[must_use]
326    pub fn partial_failure(order_results: OrderResultMap) -> Self {
327        let successful_orders = order_results
328            .values()
329            .filter(|result| matches!(result, OrderResult::Success(())))
330            .count();
331        let failed_orders = order_results.len() - successful_orders;
332
333        Self::PartialFailure {
334            successful_orders,
335            failed_orders,
336            order_results,
337        }
338    }
339
340    /// Create an all-orders-failed error
341    #[must_use]
342    pub fn all_failed(order_results: OrderResultMap) -> Self {
343        let total_orders = order_results.len();
344        Self::AllOrdersFailed {
345            total_orders,
346            order_results,
347        }
348    }
349
350    /// Create a batch size exceeded error
351    #[must_use]
352    pub const fn size_exceeded(actual: usize, max_allowed: usize) -> Self {
353        Self::BatchSizeExceeded {
354            actual,
355            max_allowed,
356        }
357    }
358
359    /// Check if this error suggests the operation might succeed on retry
360    #[must_use]
361    pub fn is_retryable(&self) -> bool {
362        match self {
363            Self::TransportError { is_retryable, .. } => *is_retryable,
364            Self::PartialFailure { order_results, .. } => {
365                // Partial failures are retryable if any failed orders are retryable
366                order_results.values().any(|result| {
367                    matches!(
368                        result,
369                        OrderResult::Failed {
370                            is_retryable: true,
371                            ..
372                        }
373                    )
374                })
375            }
376            Self::AllOrdersFailed { order_results, .. } => {
377                // All failed is retryable if any orders are retryable
378                order_results.values().any(|result| {
379                    matches!(
380                        result,
381                        OrderResult::Failed {
382                            is_retryable: true,
383                            ..
384                        }
385                    )
386                })
387            }
388            Self::EmptyBatch | Self::BatchSizeExceeded { .. } => false,
389        }
390    }
391
392    /// Get the orders that can be retried from this error
393    #[must_use]
394    pub fn get_retryable_orders(&self) -> SmallVec<[Order; 8]> {
395        match self {
396            Self::TransportError { .. } => {
397                // For transport errors, we don't have access to individual orders here
398                // The caller should handle this case by retrying the entire batch
399                SmallVec::new()
400            }
401            Self::PartialFailure { order_results, .. }
402            | Self::AllOrdersFailed { order_results, .. } => order_results
403                .values()
404                .filter_map(|result| {
405                    if let OrderResult::Failed {
406                        order,
407                        is_retryable: true,
408                        ..
409                    } = result
410                    {
411                        Some((**order).clone())
412                    } else {
413                        None
414                    }
415                })
416                .collect(),
417            Self::EmptyBatch | Self::BatchSizeExceeded { .. } => SmallVec::new(),
418        }
419    }
420
421    /// Get detailed error statistics for monitoring and debugging
422    #[must_use]
423    pub fn get_error_stats(&self) -> BatchErrorStats {
424        match self {
425            Self::TransportError {
426                affected_orders,
427                is_retryable,
428                ..
429            } => BatchErrorStats {
430                total_orders: *affected_orders,
431                transport_failures: 1,
432                retryable_failures: usize::from(*is_retryable),
433                ..Default::default()
434            },
435            Self::PartialFailure {
436                successful_orders,
437                failed_orders,
438                order_results,
439                ..
440            } => {
441                let retryable_failures = order_results
442                    .values()
443                    .filter(|result| {
444                        matches!(
445                            result,
446                            OrderResult::Failed {
447                                is_retryable: true,
448                                ..
449                            }
450                        )
451                    })
452                    .count();
453
454                BatchErrorStats {
455                    total_orders: successful_orders + failed_orders,
456                    successful_orders: *successful_orders,
457                    per_order_failures: *failed_orders,
458                    retryable_failures,
459                    ..Default::default()
460                }
461            }
462            Self::AllOrdersFailed {
463                total_orders,
464                order_results,
465                ..
466            } => {
467                let retryable_failures = order_results
468                    .values()
469                    .filter(|result| {
470                        matches!(
471                            result,
472                            OrderResult::Failed {
473                                is_retryable: true,
474                                ..
475                            }
476                        )
477                    })
478                    .count();
479
480                BatchErrorStats {
481                    total_orders: *total_orders,
482                    per_order_failures: *total_orders,
483                    retryable_failures,
484                    ..Default::default()
485                }
486            }
487            Self::EmptyBatch => BatchErrorStats::default(),
488            Self::BatchSizeExceeded { actual, .. } => BatchErrorStats {
489                total_orders: *actual,
490                validation_failures: 1,
491                ..Default::default()
492            },
493        }
494    }
495}
496
497/// Statistics for batch error analysis
498#[derive(Debug, Clone, Default)]
499pub struct BatchErrorStats {
500    /// Total number of orders in the batch
501    pub total_orders: usize,
502    /// Number of orders that succeeded
503    pub successful_orders: usize,
504    /// Number of transport-level failures
505    pub transport_failures: usize,
506    /// Number of per-order failures
507    pub per_order_failures: usize,
508    /// Number of validation failures (batch-level)
509    pub validation_failures: usize,
510    /// Number of failures that can be retried
511    pub retryable_failures: usize,
512}
513
514impl<T> OrderResult<T> {
515    /// Create a successful order result
516    pub const fn success(value: T) -> Self {
517        Self::Success(value)
518    }
519
520    /// Create a failed order result
521    #[must_use]
522    pub fn failed(error: EMSError, order: Order) -> Self {
523        let is_retryable = error.is_recoverable();
524        Self::Failed {
525            error,
526            order: Box::new(order),
527            is_retryable,
528        }
529    }
530
531    /// Check if this result represents success
532    pub const fn is_success(&self) -> bool {
533        matches!(self, Self::Success(_))
534    }
535
536    /// Check if this result can be retried
537    pub const fn is_retryable(&self) -> bool {
538        matches!(
539            self,
540            Self::Failed {
541                is_retryable: true,
542                ..
543            }
544        )
545    }
546
547    /// Get the error if this is a failure
548    pub const fn error(&self) -> Option<&EMSError> {
549        match self {
550            Self::Failed { error, .. } => Some(error),
551            _ => None,
552        }
553    }
554
555    /// Get the order if this is a failure
556    pub fn failed_order(&self) -> Option<&Order> {
557        match self {
558            Self::Failed { order, .. } => Some(order),
559            _ => None,
560        }
561    }
562}
563
564/// Helper trait for converting `EMSError` to determine if per-order errors
565pub trait ErrorClassification {
566    /// Check if this error is a per-order validation error (not transport-level)
567    fn is_per_order_error(&self) -> bool;
568
569    /// Check if this error is a transport-level error affecting the entire batch
570    fn is_transport_error(&self) -> bool;
571}
572
573impl ErrorClassification for EMSError {
574    fn is_per_order_error(&self) -> bool {
575        matches!(
576            self,
577            Self::InvalidOrderParameters(_)
578                | Self::InsufficientBalance(_)
579                | Self::InstrumentNotFound(_)
580                | Self::OrderSubmissionError(_)
581                | Self::OrderCancellationError(_)
582                | Self::OrderModificationError(_)
583        )
584    }
585
586    fn is_transport_error(&self) -> bool {
587        matches!(
588            self,
589            Self::ConnectionError(_)
590                | Self::AuthenticationError(_)
591                | Self::RateLimitExceeded { .. }
592                | Self::NetworkError(_)
593                | Self::Timeout { .. }
594                | Self::WebSocketError(_)
595                | Self::WebSocketFrameError(_)
596        )
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use rust_decimal_macros::dec;
604    use rusty_model::{
605        enums::{OrderSide, OrderType},
606        trading_order::Order,
607        types::ClientId,
608        venues::Venue,
609    };
610
611    fn create_test_order(symbol: &str) -> Order {
612        Order::new(
613            Venue::Binance,
614            symbol,
615            OrderSide::Buy,
616            OrderType::Limit,
617            dec!(100),
618            Some(dec!(50000)),
619            ClientId::from("test_client"),
620        )
621    }
622
623    #[test]
624    fn test_batch_result_creation() {
625        let mut order_results = OrderResultMap::default();
626        order_results.insert("order1".into(), OrderResult::success(()));
627        order_results.insert("order2".into(), OrderResult::success(()));
628
629        let result = BatchResult::success(order_results, 1000000);
630
631        assert_eq!(result.status, BatchStatus::AllSucceeded);
632        assert_eq!(result.summary.total_orders, 2);
633        assert_eq!(result.summary.successful_orders, 2);
634        assert_eq!(result.summary.failed_orders, 0);
635        assert_eq!(result.success_rate(), 100.0);
636    }
637
638    #[test]
639    fn test_partial_failure_result() {
640        let mut order_results = OrderResultMap::default();
641        order_results.insert("order1".into(), OrderResult::success(()));
642        order_results.insert(
643            "order2".into(),
644            OrderResult::failed(
645                EMSError::invalid_params("Invalid price"),
646                create_test_order("BTCUSDT"),
647            ),
648        );
649
650        let result = BatchResult::partial_success(order_results, 1500000);
651
652        assert_eq!(result.status, BatchStatus::PartialSuccess);
653        assert_eq!(result.summary.successful_orders, 1);
654        assert_eq!(result.summary.failed_orders, 1);
655        assert_eq!(result.success_rate(), 50.0);
656        assert!(result.has_successes());
657        assert!(result.has_failures());
658    }
659
660    #[test]
661    fn test_transport_error_classification() {
662        let connection_error = EMSError::connection("Network down");
663        let validation_error = EMSError::invalid_params("Bad price");
664
665        assert!(connection_error.is_transport_error());
666        assert!(!connection_error.is_per_order_error());
667
668        assert!(!validation_error.is_transport_error());
669        assert!(validation_error.is_per_order_error());
670    }
671
672    #[test]
673    fn test_retryable_orders_extraction() {
674        let mut order_results = OrderResultMap::default();
675
676        // Non-retryable error
677        order_results.insert(
678            "order1".into(),
679            OrderResult::<()>::failed(
680                EMSError::invalid_params("Invalid symbol"),
681                create_test_order("INVALID"),
682            ),
683        );
684
685        // Retryable error
686        order_results.insert(
687            "order2".into(),
688            OrderResult::failed(
689                EMSError::connection("Temporary network issue"),
690                create_test_order("BTCUSDT"),
691            ),
692        );
693
694        let result = BatchResult::partial_success(order_results, 1000000);
695        let retryable = result.get_retryable_orders();
696
697        assert_eq!(retryable.len(), 1);
698        assert_eq!(retryable[0].symbol, "BTCUSDT");
699    }
700
701    #[test]
702    fn test_error_grouping() {
703        let mut order_results = OrderResultMap::default();
704
705        order_results.insert(
706            "order1".into(),
707            OrderResult::<()>::failed(
708                EMSError::invalid_params("Bad price"),
709                create_test_order("BTCUSDT"),
710            ),
711        );
712
713        order_results.insert(
714            "order2".into(),
715            OrderResult::<()>::failed(
716                EMSError::invalid_params("Bad quantity"),
717                create_test_order("ETHUSDT"),
718            ),
719        );
720
721        order_results.insert(
722            "order3".into(),
723            OrderResult::failed(
724                EMSError::insufficient_balance("Not enough USDT"),
725                create_test_order("ADAUSDT"),
726            ),
727        );
728
729        let result = BatchResult::all_failed(order_results, 1000000);
730        let failures_by_type = result.get_failures_by_error_type();
731
732        assert_eq!(failures_by_type.get("Invalid Parameters").unwrap().len(), 2);
733        assert_eq!(
734            failures_by_type.get("Insufficient Balance").unwrap().len(),
735            1
736        );
737    }
738
739    #[test]
740    fn test_batch_status_all_succeeded() {
741        let mut order_results = OrderResultMap::default();
742        order_results.insert("order1".into(), OrderResult::success(()));
743        order_results.insert("order2".into(), OrderResult::success(()));
744        order_results.insert("order3".into(), OrderResult::success(()));
745
746        let result = BatchResult::success(order_results, 1000000);
747
748        assert_eq!(result.status, BatchStatus::AllSucceeded);
749        assert_eq!(result.summary.total_orders, 3);
750        assert_eq!(result.summary.successful_orders, 3);
751        assert_eq!(result.summary.failed_orders, 0);
752        assert_eq!(result.summary.retryable_orders, 0);
753        assert!(result.transport_error.is_none());
754        assert!(result.has_successes());
755        assert!(!result.has_failures());
756        assert!(!result.has_retryable_orders());
757        assert_eq!(result.success_rate(), 100.0);
758    }
759
760    #[test]
761    fn test_batch_status_partial_success() {
762        let mut order_results = OrderResultMap::default();
763        order_results.insert("order1".into(), OrderResult::success(()));
764        order_results.insert("order2".into(), OrderResult::success(()));
765        order_results.insert(
766            "order3".into(),
767            OrderResult::failed(
768                EMSError::invalid_params("Invalid price"),
769                create_test_order("BTCUSDT"),
770            ),
771        );
772        order_results.insert(
773            "order4".into(),
774            OrderResult::failed(
775                EMSError::connection("Network issue"), // This is retryable
776                create_test_order("ETHUSDT"),
777            ),
778        );
779
780        let result = BatchResult::partial_success(order_results, 2000000);
781
782        assert_eq!(result.status, BatchStatus::PartialSuccess);
783        assert_eq!(result.summary.total_orders, 4);
784        assert_eq!(result.summary.successful_orders, 2);
785        assert_eq!(result.summary.failed_orders, 2);
786        assert_eq!(result.summary.retryable_orders, 1); // Only the connection error
787        assert!(result.transport_error.is_none());
788        assert!(result.has_successes());
789        assert!(result.has_failures());
790        assert!(result.has_retryable_orders());
791        assert_eq!(result.success_rate(), 50.0);
792
793        // Check retryable orders
794        let retryable = result.get_retryable_orders();
795        assert_eq!(retryable.len(), 1);
796        assert_eq!(retryable[0].symbol, "ETHUSDT");
797    }
798
799    #[test]
800    fn test_batch_status_all_failed() {
801        let mut order_results = OrderResultMap::default();
802        order_results.insert(
803            "order1".into(),
804            OrderResult::<()>::failed(
805                EMSError::invalid_params("Bad price"),
806                create_test_order("BTCUSDT"),
807            ),
808        );
809        order_results.insert(
810            "order2".into(),
811            OrderResult::<()>::failed(
812                EMSError::insufficient_balance("Not enough funds"),
813                create_test_order("ETHUSDT"),
814            ),
815        );
816        order_results.insert(
817            "order3".into(),
818            OrderResult::failed(
819                EMSError::connection("Temporary network issue"), // Retryable
820                create_test_order("ADAUSDT"),
821            ),
822        );
823
824        let result = BatchResult::all_failed(order_results, 3000000);
825
826        assert_eq!(result.status, BatchStatus::AllFailed);
827        assert_eq!(result.summary.total_orders, 3);
828        assert_eq!(result.summary.successful_orders, 0);
829        assert_eq!(result.summary.failed_orders, 3);
830        assert_eq!(result.summary.retryable_orders, 1); // Only the connection error
831        assert!(result.transport_error.is_none());
832        assert!(!result.has_successes());
833        assert!(result.has_failures());
834        assert!(result.has_retryable_orders());
835        assert_eq!(result.success_rate(), 0.0);
836
837        // Verify error grouping
838        let failures_by_type = result.get_failures_by_error_type();
839        assert_eq!(failures_by_type.get("Invalid Parameters").unwrap().len(), 1);
840        assert_eq!(
841            failures_by_type.get("Insufficient Balance").unwrap().len(),
842            1
843        );
844        assert_eq!(failures_by_type.get("Other").unwrap().len(), 1); // Connection error
845    }
846
847    #[test]
848    fn test_batch_status_transport_failure() {
849        let transport_error = EMSError::connection("Exchange API down");
850        let result = BatchResult::<()>::transport_failure(
851            transport_error.clone(),
852            5, // 5 orders affected
853            500000,
854        );
855
856        assert_eq!(result.status, BatchStatus::TransportFailure);
857        assert_eq!(result.summary.total_orders, 5);
858        assert_eq!(result.summary.successful_orders, 0);
859        assert_eq!(result.summary.failed_orders, 5);
860        assert_eq!(result.summary.retryable_orders, 5); // Connection errors are retryable
861        assert!(result.transport_error.is_some());
862        assert_eq!(
863            result.transport_error.as_ref().unwrap().to_string(),
864            transport_error.to_string()
865        );
866        assert!(result.order_results.is_empty()); // No individual order results
867        assert!(!result.has_successes());
868        assert!(result.has_failures());
869        assert!(result.has_retryable_orders());
870        assert_eq!(result.success_rate(), 0.0);
871    }
872
873    #[test]
874    fn test_batch_status_transport_failure_non_retryable() {
875        let transport_error = EMSError::auth("Invalid API key");
876        let result = BatchResult::<()>::transport_failure(transport_error, 3, 750000);
877
878        assert_eq!(result.status, BatchStatus::TransportFailure);
879        assert_eq!(result.summary.total_orders, 3);
880        assert_eq!(result.summary.successful_orders, 0);
881        assert_eq!(result.summary.failed_orders, 3);
882        assert_eq!(result.summary.retryable_orders, 0); // Auth errors are not retryable
883        assert!(result.transport_error.is_some());
884        assert!(!result.has_retryable_orders());
885    }
886
887    #[test]
888    fn test_empty_batch_result() {
889        let order_results: OrderResultMap = FxHashMap::default();
890        let result = BatchResult::success(order_results, 100000);
891
892        assert_eq!(result.status, BatchStatus::AllSucceeded);
893        assert_eq!(result.summary.total_orders, 0);
894        assert_eq!(result.summary.successful_orders, 0);
895        assert_eq!(result.summary.failed_orders, 0);
896        assert_eq!(result.success_rate(), 0.0); // Division by zero handled
897        assert!(!result.has_successes());
898        assert!(!result.has_failures());
899    }
900}