rusty_ems/exchanges/
bybit_rest.rs

1//! Bybit V5 REST API Implementation
2//!
3//! Comprehensive REST API client for Bybit V5, covering essential trading operations:
4//! - Order management (create, cancel, batch operations)
5//! - Account queries (wallet balance, positions)
6//! - Order queries (open orders, order history)
7//!
8//! Reference: <https://bybit-exchange.github.io/docs/v5>/
9
10use crate::error::Result as EMSResult;
11use crate::error::{
12    EMSError,
13    batch_errors::{BatchResult, OrderResult},
14};
15use crate::execution_engine::ExecutionReport;
16use flume::Sender;
17use rust_decimal::Decimal;
18use rusty_common::collections::FxHashMap;
19use rusty_model::enums::{OrderSide, OrderType, TimeInForce};
20use serde::{Deserialize, Serialize};
21use simd_json;
22use smallvec::SmallVec;
23use smartstring::alias::String as SmartString;
24
25// V5 API Constants and Enums
26
27/// Bybit V5 product categories
28pub mod v5_constants {
29    /// Product category for spot trading on Bybit V5 API
30    pub const CATEGORY_SPOT: &str = "spot";
31    /// Product category for linear perpetual futures on Bybit V5 API
32    pub const CATEGORY_LINEAR: &str = "linear";
33    /// Product category for inverse perpetual futures on Bybit V5 API
34    pub const CATEGORY_INVERSE: &str = "inverse";
35    /// Product category for options trading on Bybit V5 API
36    pub const CATEGORY_OPTION: &str = "option";
37
38    /// Market unit for spot trading orders using base coin quantity
39    pub const MARKET_UNIT_BASE_COIN: &str = "baseCoin";
40    /// Market unit for spot trading orders using quote coin quantity
41    pub const MARKET_UNIT_QUOTE_COIN: &str = "quoteCoin";
42
43    /// Slippage tolerance type measured in tick size units
44    pub const SLIPPAGE_TICK_SIZE: &str = "TickSize";
45    /// Slippage tolerance type measured as percentage
46    pub const SLIPPAGE_PERCENT: &str = "Percent";
47
48    /// Order filter for regular spot orders
49    pub const ORDER_FILTER_ORDER: &str = "Order";
50    /// Order filter for take profit/stop loss orders
51    pub const ORDER_FILTER_TPSL_ORDER: &str = "tpslOrder";
52    /// Order filter for stop orders
53    pub const ORDER_FILTER_STOP_ORDER: &str = "StopOrder";
54
55    /// Trigger price type based on last trade price
56    pub const TRIGGER_BY_LAST_PRICE: &str = "LastPrice";
57    /// Trigger price type based on index price
58    pub const TRIGGER_BY_INDEX_PRICE: &str = "IndexPrice";
59    /// Trigger price type based on mark price
60    pub const TRIGGER_BY_MARK_PRICE: &str = "MarkPrice";
61
62    /// Trigger direction indicating price rise to trigger order
63    pub const TRIGGER_DIRECTION_RISE: u8 = 1;
64    /// Trigger direction indicating price fall to trigger order
65    pub const TRIGGER_DIRECTION_FALL: u8 = 2;
66
67    /// Take profit/stop loss mode affecting entire position
68    pub const TPSL_MODE_FULL: &str = "Full";
69    /// Take profit/stop loss mode affecting partial position
70    pub const TPSL_MODE_PARTIAL: &str = "Partial";
71
72    /// Market order type for take profit/stop loss execution
73    pub const ORDER_TYPE_MARKET: &str = "Market";
74    /// Limit order type for take profit/stop loss execution
75    pub const ORDER_TYPE_LIMIT: &str = "Limit";
76
77    /// Self-match prevention disabled - no action taken
78    pub const SMP_NONE: &str = "None";
79    /// Self-match prevention cancels the maker order
80    pub const SMP_CANCEL_MAKER: &str = "CancelMaker";
81    /// Self-match prevention cancels the taker order
82    pub const SMP_CANCEL_TAKER: &str = "CancelTaker";
83    /// Self-match prevention cancels both orders
84    pub const SMP_CANCEL_BOTH: &str = "CancelBoth";
85
86    /// Leverage disabled for spot trading (0)
87    pub const LEVERAGE_DISABLED: u8 = 0;
88    /// Leverage enabled for margin trading (1)
89    pub const LEVERAGE_ENABLED: u8 = 1;
90}
91
92/// V5 Order validation helpers
93pub mod v5_validation {
94    use rust_decimal::Decimal;
95
96    /// Validate slippage tolerance based on type
97    pub fn validate_slippage_tolerance(tolerance_type: &str, tolerance: Decimal) -> bool {
98        match tolerance_type {
99            "TickSize" => tolerance >= Decimal::from(5) && tolerance <= Decimal::from(2000),
100            "Percent" => tolerance >= Decimal::new(5, 2) && tolerance <= Decimal::ONE, // 0.05 to 1.0
101            _ => false,
102        }
103    }
104
105    /// Check if category supports unified account features
106    pub fn supports_unified_account(category: &str) -> bool {
107        matches!(category, "spot" | "linear" | "inverse" | "option")
108    }
109
110    /// Check if category supports conditional orders
111    pub fn supports_conditional_orders(category: &str) -> bool {
112        matches!(category, "spot" | "linear" | "inverse")
113    }
114
115    /// Check if category supports options-specific features
116    pub fn supports_options_features(category: &str) -> bool {
117        matches!(category, "option")
118    }
119}
120
121/// Common order parameters for Bybit order creation
122#[derive(Debug, Clone)]
123pub struct CommonOrderParams<'a> {
124    /// Product category: "spot", "linear", "inverse", "option"
125    pub category: &'a str,
126    /// Trading symbol (e.g., "BTCUSDT")
127    pub symbol: &'a str,
128    /// Order side: Buy or Sell
129    pub side: OrderSide,
130    /// Order type: Market, Limit, etc.
131    pub order_type: OrderType,
132    /// Order quantity
133    pub quantity: Decimal,
134    /// Limit price (required for limit orders)
135    pub price: Option<Decimal>,
136    /// Time in force: GTC, IOC, FOK, etc.
137    pub time_in_force: Option<TimeInForce>,
138    /// Client-provided order ID
139    pub client_order_id: Option<&'a str>,
140}
141
142/// Bybit V5 REST API client
143#[derive(Debug)]
144pub struct BybitRestClient {
145    base_url: SmartString,
146    api_key: SmartString,
147    secret_key: SmartString,
148    client: reqwest::Client,
149    recv_window: u64,
150}
151
152/// Bybit V5 order request parameters
153/// Comprehensive support for all V5 order types and parameters
154#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct BybitOrderRequest {
157    // Required fields
158    /// Product category: "spot", "linear", "inverse", "option"
159    pub category: SmartString,
160    /// Trading symbol (e.g., "BTCUSDT")
161    pub symbol: SmartString,
162    /// Order side: "Buy" or "Sell"
163    pub side: SmartString,
164    /// Order type: "Market", "Limit", "StopMarket", "StopLimit", etc.
165    pub order_type: SmartString,
166    /// Order quantity
167    pub qty: SmartString,
168
169    // Basic order parameters
170    #[serde(skip_serializing_if = "Option::is_none")]
171    /// Order price (required for limit orders)
172    pub price: Option<SmartString>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    /// Time in force: "GTC", "IOC", "FOK", "PostOnly"
175    pub time_in_force: Option<SmartString>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    /// Client order ID for tracking
178    pub order_link_id: Option<SmartString>,
179
180    // Position management
181    #[serde(skip_serializing_if = "Option::is_none")]
182    /// Position index for unified account: 0 (one-way), 1 (hedge-buy), 2 (hedge-sell)
183    pub position_idx: Option<u8>,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    /// Reduce only order flag
186    pub reduce_only: Option<bool>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    /// Close position on trigger for stop orders
189    pub close_on_trigger: Option<bool>,
190
191    // V5 Unified Account - Spot Trading
192    /// Whether to borrow (margin trading). Unified account Spot trading only.
193    /// 0: false (spot trading), 1: true (margin trading)
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub is_leverage: Option<u8>,
196
197    /// Unit for qty when creating Spot market orders for UTA account
198    /// "baseCoin" or "quoteCoin"
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub market_unit: Option<SmartString>,
201
202    // V5 Market Order Slippage Control
203    /// Slippage tolerance type: "TickSize" or "Percent"
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub slippage_tolerance_type: Option<SmartString>,
206
207    /// Slippage tolerance value
208    /// TickSize: range [5, 2000], Percent: range [0.05, 1]
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub slippage_tolerance: Option<SmartString>,
211
212    // V5 Spot Order Filters
213    /// Order filter: "Order", "tpslOrder", "StopOrder" (Spot only)
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub order_filter: Option<SmartString>,
216
217    // V5 Conditional Orders
218    /// Conditional order trigger direction: 1 (rise to trigger), 2 (fall to trigger)
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub trigger_direction: Option<u8>,
221
222    /// Conditional order trigger price
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub trigger_price: Option<SmartString>,
225
226    /// Trigger price type: "LastPrice", "IndexPrice", "MarkPrice"
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub trigger_by: Option<SmartString>,
229
230    // V5 Options
231    /// Implied volatility for option orders (real value, e.g., 0.1 for 10%)
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub order_iv: Option<SmartString>,
234
235    // V5 Take Profit / Stop Loss
236    /// Take profit price
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub take_profit: Option<SmartString>,
239
240    /// Stop loss price
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub stop_loss: Option<SmartString>,
243
244    /// Take profit trigger price type: "MarkPrice", "IndexPrice", "LastPrice"
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub tp_trigger_by: Option<SmartString>,
247
248    /// Stop loss trigger price type: "MarkPrice", "IndexPrice", "LastPrice"
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub sl_trigger_by: Option<SmartString>,
251
252    /// TP/SL mode: "Full" (entire position), "Partial" (partial position)
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub tpsl_mode: Option<SmartString>,
255
256    /// Limit order price when take profit is triggered
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub tp_limit_price: Option<SmartString>,
259
260    /// Limit order price when stop loss is triggered
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub sl_limit_price: Option<SmartString>,
263
264    /// Order type when take profit is triggered: "Market", "Limit"
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub tp_order_type: Option<SmartString>,
267
268    /// Order type when stop loss is triggered: "Market", "Limit"
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub sl_order_type: Option<SmartString>,
271
272    // V5 Self-Match Prevention and Market Maker Protection
273    /// Self-match prevention type: "None", "CancelMaker", "CancelTaker", "CancelBoth"
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub smp_type: Option<SmartString>,
276
277    /// Market maker protection (option only)
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub mmp: Option<bool>,
280}
281
282impl BybitOrderRequest {
283    /// Create a V5 order request from common order parameters
284    /// Categories: "spot", "linear", "inverse", "option"
285    pub fn from_common(params: CommonOrderParams) -> EMSResult<Self> {
286        Self::from_common_with_category(params)
287    }
288
289    /// Create a V5 order request from common order parameters with spot category (backwards compatibility)
290    /// Defaults to spot category for backwards compatibility with existing tests
291    pub fn from_common_spot(
292        symbol: &str,
293        side: OrderSide,
294        order_type: OrderType,
295        quantity: Decimal,
296        price: Option<Decimal>,
297        time_in_force: Option<TimeInForce>,
298        client_order_id: Option<&str>,
299    ) -> EMSResult<Self> {
300        let params = CommonOrderParams {
301            category: "spot",
302            symbol,
303            side,
304            order_type,
305            quantity,
306            price,
307            time_in_force,
308            client_order_id,
309        };
310        Self::from_common_with_category(params)
311    }
312
313    /// Create a builder for conditional orders (Stop/StopLimit)
314    /// Use this method to create orders with trigger_price and other conditional parameters
315    pub fn builder() -> BybitOrderRequestBuilder {
316        BybitOrderRequestBuilder::new()
317    }
318
319    /// Create a V5 order request from common order parameters with specified category
320    /// Categories: "spot", "linear", "inverse", "option"
321    pub fn from_common_with_category(params: CommonOrderParams) -> EMSResult<Self> {
322        // Extract parameters from struct
323        let CommonOrderParams {
324            category,
325            symbol,
326            side,
327            order_type,
328            quantity,
329            price,
330            time_in_force,
331            client_order_id,
332        } = params;
333
334        // Convert enums to strings for V5 API
335        let side_str = match side {
336            OrderSide::Buy => "Buy",
337            OrderSide::Sell => "Sell",
338        };
339
340        let order_type_str = match order_type {
341            OrderType::Market => "Market",
342            OrderType::Limit => "Limit",
343            OrderType::Stop => {
344                return Err(EMSError::InvalidOrderParameters(
345                    format!("Stop orders require conditional order parameters. Use BybitOrderRequest::builder() with trigger_price and trigger_by fields for category '{category}'").into(),
346                ));
347            }
348            OrderType::StopLimit => {
349                return Err(EMSError::InvalidOrderParameters(
350                    format!("StopLimit orders require conditional order parameters. Use BybitOrderRequest::builder() with trigger_price and trigger_by fields for category '{category}'").into(),
351                ));
352            }
353            OrderType::FillOrKill => "Limit", // Map to Limit with FOK time_in_force
354            OrderType::ImmediateOrCancel => "Limit", // Map to Limit with IOC time_in_force
355            OrderType::PostOnly => "Limit",   // Map to Limit with PostOnly time_in_force
356        };
357
358        // Handle special cases where order type implies time_in_force
359        let tif_str = match order_type {
360            OrderType::FillOrKill => Some("FOK"),
361            OrderType::ImmediateOrCancel => Some("IOC"),
362            OrderType::PostOnly => Some("PostOnly"),
363            _ => time_in_force
364                .map(|tif| match tif {
365                    TimeInForce::GTC => Ok("GTC"),
366                    TimeInForce::IOC => Ok("IOC"),
367                    TimeInForce::FOK => Ok("FOK"),
368                    TimeInForce::GTD => Err(EMSError::InvalidOrderParameters(
369                        "GTD time in force not supported by Bybit V5 API".into(),
370                    )),
371                    TimeInForce::GTX => Ok("PostOnly"), // V5: GTX maps to PostOnly (consistent with WebSocket implementation)
372                })
373                .transpose()?,
374        };
375
376        Ok(Self {
377            category: category.into(),
378            symbol: symbol.into(),
379            side: side_str.into(),
380            order_type: order_type_str.into(),
381            qty: quantity.to_string().into(),
382            price: price.map(|p| p.to_string().into()),
383            time_in_force: tif_str.map(SmartString::from),
384            order_link_id: client_order_id.map(SmartString::from),
385
386            // Default all V5 optional fields to None
387            position_idx: None,
388            reduce_only: None,
389            close_on_trigger: None,
390            is_leverage: None,
391            market_unit: None,
392            slippage_tolerance_type: None,
393            slippage_tolerance: None,
394            order_filter: None,
395            trigger_direction: None,
396            trigger_price: None,
397            trigger_by: None,
398            order_iv: None,
399            take_profit: None,
400            stop_loss: None,
401            tp_trigger_by: None,
402            sl_trigger_by: None,
403            tpsl_mode: None,
404            tp_limit_price: None,
405            sl_limit_price: None,
406            tp_order_type: None,
407            sl_order_type: None,
408            smp_type: None,
409            mmp: None,
410        })
411    }
412}
413
414/// Builder for creating Bybit V5 order requests with conditional parameters
415/// Particularly useful for Stop/StopLimit orders that require trigger_price
416#[derive(Debug)]
417pub struct BybitOrderRequestBuilder {
418    request: BybitOrderRequest,
419}
420
421impl Default for BybitOrderRequestBuilder {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl BybitOrderRequestBuilder {
428    /// Create a new builder
429    pub fn new() -> Self {
430        Self {
431            request: BybitOrderRequest {
432                category: "spot".into(),
433                symbol: "".into(),
434                side: "".into(),
435                order_type: "".into(),
436                qty: "".into(),
437                price: None,
438                time_in_force: None,
439                order_link_id: None,
440                position_idx: None,
441                reduce_only: None,
442                close_on_trigger: None,
443                is_leverage: None,
444                market_unit: None,
445                slippage_tolerance_type: None,
446                slippage_tolerance: None,
447                order_filter: None,
448                trigger_direction: None,
449                trigger_price: None,
450                trigger_by: None,
451                order_iv: None,
452                take_profit: None,
453                stop_loss: None,
454                tp_trigger_by: None,
455                sl_trigger_by: None,
456                tpsl_mode: None,
457                tp_limit_price: None,
458                sl_limit_price: None,
459                tp_order_type: None,
460                sl_order_type: None,
461                smp_type: None,
462                mmp: None,
463            },
464        }
465    }
466
467    /// Set the category
468    pub fn category(mut self, category: &str) -> Self {
469        self.request.category = category.into();
470        self
471    }
472
473    /// Set the symbol
474    pub fn symbol(mut self, symbol: &str) -> Self {
475        self.request.symbol = symbol.into();
476        self
477    }
478
479    /// Set the side
480    pub fn side(mut self, side: OrderSide) -> Self {
481        self.request.side = match side {
482            OrderSide::Buy => "Buy",
483            OrderSide::Sell => "Sell",
484        }
485        .into();
486        self
487    }
488
489    /// Set the order type
490    pub fn order_type(mut self, order_type: OrderType) -> Self {
491        self.request.order_type = match order_type {
492            OrderType::Market => "Market",
493            OrderType::Limit => "Limit",
494            OrderType::Stop => "Market", // Stop orders are market orders with trigger
495            OrderType::StopLimit => "Limit", // StopLimit orders are limit orders with trigger
496            OrderType::FillOrKill => "Limit",
497            OrderType::ImmediateOrCancel => "Limit",
498            OrderType::PostOnly => "Limit",
499        }
500        .into();
501        self
502    }
503
504    /// Set the quantity
505    pub fn quantity(mut self, quantity: Decimal) -> Self {
506        self.request.qty = quantity.to_string().into();
507        self
508    }
509
510    /// Set the price
511    pub fn price(mut self, price: Decimal) -> Self {
512        self.request.price = Some(price.to_string().into());
513        self
514    }
515
516    /// Set the time in force
517    pub fn time_in_force(mut self, tif: TimeInForce) -> EMSResult<Self> {
518        let tif_str = match tif {
519            TimeInForce::GTC => "GTC",
520            TimeInForce::IOC => "IOC",
521            TimeInForce::FOK => "FOK",
522            TimeInForce::GTD => {
523                return Err(EMSError::InvalidOrderParameters(
524                    "GTD time in force not supported by Bybit V5 API".into(),
525                ));
526            }
527            TimeInForce::GTX => "PostOnly", // V5: GTX maps to PostOnly
528        };
529        self.request.time_in_force = Some(tif_str.into());
530        Ok(self)
531    }
532
533    /// Set the client order ID
534    pub fn client_order_id(mut self, id: &str) -> Self {
535        self.request.order_link_id = Some(id.into());
536        self
537    }
538
539    /// Set the trigger price (required for Stop/StopLimit orders)
540    pub fn trigger_price(mut self, price: Decimal) -> Self {
541        self.request.trigger_price = Some(price.to_string().into());
542        self
543    }
544
545    /// Set the trigger price type
546    pub fn trigger_by(mut self, trigger_by: &str) -> Self {
547        self.request.trigger_by = Some(trigger_by.into());
548        self
549    }
550
551    /// Set the trigger direction
552    pub const fn trigger_direction(mut self, direction: u8) -> Self {
553        self.request.trigger_direction = Some(direction);
554        self
555    }
556
557    /// Build the order request
558    pub fn build(self) -> EMSResult<BybitOrderRequest> {
559        // Validate required fields
560        if self.request.symbol.is_empty() {
561            return Err(EMSError::InvalidOrderParameters(
562                "Symbol is required".into(),
563            ));
564        }
565        if self.request.side.is_empty() {
566            return Err(EMSError::InvalidOrderParameters("Side is required".into()));
567        }
568        if self.request.order_type.is_empty() {
569            return Err(EMSError::InvalidOrderParameters(
570                "Order type is required".into(),
571            ));
572        }
573        if self.request.qty.is_empty() {
574            return Err(EMSError::InvalidOrderParameters(
575                "Quantity is required".into(),
576            ));
577        }
578
579        Ok(self.request)
580    }
581}
582
583/// Bybit batch order request
584#[derive(Debug, Serialize)]
585pub struct BybitBatchOrderRequest {
586    /// Product category for the batch
587    pub category: SmartString,
588    /// List of order requests to process in batch
589    pub request: SmallVec<[BybitOrderRequest; 10]>,
590}
591
592/// Bybit order response
593#[derive(Debug, Clone, Deserialize)]
594#[serde(rename_all = "camelCase")]
595pub struct BybitOrderResponse {
596    /// Order ID generated by Bybit
597    pub order_id: SmartString,
598    /// Client order ID provided in the request
599    pub order_link_id: SmartString,
600}
601
602/// Bybit batch order response
603#[derive(Debug, Deserialize)]
604pub struct BybitBatchOrderResponse {
605    /// List of order responses from the batch operation
606    pub list: Vec<BybitOrderResponse>,
607}
608
609/// Bybit position information
610#[derive(Debug, Deserialize)]
611pub struct BybitPosition {
612    /// Trading symbol for the position
613    pub symbol: SmartString,
614    /// Position side (Buy/Sell)
615    pub side: SmartString,
616    /// Position size
617    pub size: SmartString,
618    /// Average entry price
619    #[serde(rename = "avgPrice")]
620    pub avg_price: SmartString,
621    /// Current mark price
622    #[serde(rename = "markPrice")]
623    pub mark_price: SmartString,
624    /// Unrealized profit and loss
625    #[serde(rename = "unrealisedPnl")]
626    pub unrealised_pnl: SmartString,
627    /// Position leverage
628    pub leverage: SmartString,
629}
630
631/// Bybit wallet balance coin information
632#[derive(Debug, Deserialize)]
633pub struct BybitCoin {
634    /// Coin symbol
635    pub coin: SmartString,
636    /// Total wallet balance for this coin
637    #[serde(rename = "walletBalance")]
638    pub wallet_balance: SmartString,
639    /// Available balance for withdrawal
640    #[serde(rename = "availableToWithdraw")]
641    pub available_to_withdraw: SmartString,
642    /// Equity balance for this coin
643    pub equity: SmartString,
644    /// USD value equivalent
645    #[serde(rename = "usdValue")]
646    pub usd_value: SmartString,
647}
648
649/// Bybit wallet balance response
650#[derive(Debug, Deserialize)]
651pub struct BybitWalletBalance {
652    /// Account type identifier
653    #[serde(rename = "accountType")]
654    pub account_type: SmartString,
655    /// Total equity across all coins
656    #[serde(rename = "totalEquity")]
657    pub total_equity: SmartString,
658    /// Total wallet balance across all coins
659    #[serde(rename = "totalWalletBalance")]
660    pub total_wallet_balance: SmartString,
661    /// Total available balance for trading
662    #[serde(rename = "totalAvailableBalance")]
663    pub total_available_balance: SmartString,
664    /// List of coin balances in the wallet
665    pub coin: Vec<BybitCoin>,
666}
667
668/// Bybit V5 account types based on unifiedMarginStatus
669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum BybitAccountType {
671    /// Classic account (separate derivatives and spot)
672    Classic = 1,
673    /// Unified Trading Account 1.0
674    Uta1 = 3,
675    /// Unified Trading Account 1.0 Pro
676    Uta1Pro = 4,
677    /// Unified Trading Account 2.0
678    Uta2 = 5,
679    /// Unified Trading Account 2.0 Pro
680    Uta2Pro = 6,
681}
682
683impl BybitAccountType {
684    /// Create from unifiedMarginStatus value
685    #[must_use]
686    pub const fn from_status(status: i32) -> Option<Self> {
687        match status {
688            1 => Some(Self::Classic),
689            3 => Some(Self::Uta1),
690            4 => Some(Self::Uta1Pro),
691            5 => Some(Self::Uta2),
692            6 => Some(Self::Uta2Pro),
693            _ => None,
694        }
695    }
696
697    /// Check if account supports unified trading features
698    #[must_use]
699    pub const fn supports_unified_features(self) -> bool {
700        matches!(
701            self,
702            Self::Uta1 | Self::Uta1Pro | Self::Uta2 | Self::Uta2Pro
703        )
704    }
705
706    /// Check if account supports UTA 2.0 advanced features
707    #[must_use]
708    pub const fn supports_uta2_features(self) -> bool {
709        matches!(self, Self::Uta2 | Self::Uta2Pro)
710    }
711
712    /// Check if account supports hedge mode for inverse futures
713    #[must_use]
714    pub const fn supports_hedge_mode(self) -> bool {
715        matches!(self, Self::Classic | Self::Uta1 | Self::Uta1Pro)
716    }
717
718    /// Get account type description
719    #[must_use]
720    pub const fn description(self) -> &'static str {
721        match self {
722            Self::Classic => "Classic Account (separate derivatives and spot)",
723            Self::Uta1 => "Unified Trading Account 1.0",
724            Self::Uta1Pro => "Unified Trading Account 1.0 Pro",
725            Self::Uta2 => "Unified Trading Account 2.0",
726            Self::Uta2Pro => "Unified Trading Account 2.0 Pro",
727        }
728    }
729}
730
731/// Bybit account information response
732#[derive(Debug, Clone, Deserialize)]
733#[serde(rename_all = "camelCase")]
734pub struct BybitAccountInfo {
735    /// Account type determined by unifiedMarginStatus
736    pub unified_margin_status: i32,
737    /// Account ID
738    pub account_id: SmartString,
739    /// Account type string
740    pub account_type: SmartString,
741    /// Main UID
742    pub uid: SmartString,
743    /// Account mode information
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub account_mode: Option<SmartString>,
746    /// Master trader ID for copy trading
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub master_trader_id: Option<SmartString>,
749}
750
751impl BybitAccountInfo {
752    /// Get the account type enum from unifiedMarginStatus
753    #[must_use]
754    pub const fn get_account_type(&self) -> Option<BybitAccountType> {
755        BybitAccountType::from_status(self.unified_margin_status)
756    }
757
758    /// Check if this account supports V5 unified features
759    #[must_use]
760    pub fn supports_unified_features(&self) -> bool {
761        self.get_account_type()
762            .is_some_and(BybitAccountType::supports_unified_features)
763    }
764
765    /// Check if this account supports UTA 2.0 advanced features
766    #[must_use]
767    pub fn supports_uta2_features(&self) -> bool {
768        self.get_account_type()
769            .is_some_and(BybitAccountType::supports_uta2_features)
770    }
771}
772
773/// Bybit API response wrapper
774#[derive(Debug, Deserialize)]
775#[serde(rename_all = "camelCase")]
776pub struct BybitApiResponse<T> {
777    /// Return code from API (0 for success)
778    pub ret_code: i32,
779    /// Return message from API
780    pub ret_msg: SmartString,
781    /// Result data from API call
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub result: Option<T>,
784    /// Extended info containing error details for batch operations
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub ret_ext_info: Option<BybitExtInfo>,
787}
788
789/// Bybit extended info for batch operations
790#[derive(Debug, Deserialize)]
791pub struct BybitExtInfo {
792    /// List of error information for failed operations
793    pub list: Vec<BybitErrorInfo>,
794}
795
796/// Bybit error information for individual orders
797#[derive(Debug, Deserialize)]
798pub struct BybitErrorInfo {
799    /// Error code for the failed operation
800    pub code: i32,
801    /// Error message describing the failure
802    pub msg: SmartString,
803}
804
805impl BybitRestClient {
806    /// Create a new Bybit REST client
807    pub fn new(
808        api_key: impl Into<SmartString>,
809        secret_key: impl Into<SmartString>,
810        is_testnet: bool,
811    ) -> Self {
812        let base_url = if is_testnet {
813            "https://api-testnet.bybit.com".into()
814        } else {
815            "https://api.bybit.com".into()
816        };
817
818        Self {
819            base_url,
820            api_key: api_key.into(),
821            secret_key: secret_key.into(),
822            client: reqwest::Client::new(),
823            recv_window: 5000, // 5 seconds
824        }
825    }
826
827    /// Create a single order
828    pub async fn create_order(
829        &self,
830        request: BybitOrderRequest,
831        report_tx: Sender<ExecutionReport>,
832    ) -> EMSResult<BybitOrderResponse> {
833        let endpoint = "/v5/order/create";
834        let timestamp = self.get_timestamp();
835
836        let body = simd_json::to_string(&request).map_err(|e| {
837            EMSError::exchange_api(
838                "Bybit",
839                -1,
840                "JSON serialization failed",
841                Some(e.to_string()),
842            )
843        })?;
844
845        let signature = self.generate_signature(&timestamp, &body)?;
846
847        let response = self
848            .client
849            .post(format!("{}{endpoint}", self.base_url))
850            .header("X-BAPI-API-KEY", &*self.api_key)
851            .header("X-BAPI-SIGN", signature)
852            .header("X-BAPI-TIMESTAMP", timestamp)
853            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
854            .header("Content-Type", "application/json")
855            .body(body)
856            .send()
857            .await
858            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
859
860        let response_text = response
861            .text()
862            .await
863            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
864
865        let mut response_json = response_text.into_bytes();
866        let api_response: BybitApiResponse<BybitOrderResponse> =
867            simd_json::from_slice(&mut response_json).map_err(|e| {
868                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
869            })?;
870
871        if api_response.ret_code != 0 {
872            return Err(EMSError::exchange_api(
873                "Bybit",
874                api_response.ret_code,
875                "Order creation failed",
876                Some(api_response.ret_msg),
877            ));
878        }
879
880        api_response.result.ok_or_else(|| {
881            EMSError::exchange_api(
882                "Bybit",
883                -1,
884                "Missing result in response",
885                Some("Empty result field"),
886            )
887        })
888    }
889
890    /// Create multiple orders in batch
891    pub async fn create_batch_orders(
892        &self,
893        category: &str,
894        orders: SmallVec<[BybitOrderRequest; 10]>,
895        report_tx: Sender<ExecutionReport>,
896    ) -> EMSResult<BatchResult<BybitOrderResponse>> {
897        if orders.is_empty() {
898            return Ok(BatchResult::transport_failure(
899                EMSError::invalid_params("Empty batch: no orders to process"),
900                0,
901                0,
902            ));
903        }
904
905        // Bybit limits: 20 orders for linear/inverse/option, 10 for spot
906        let max_batch_size = match category {
907            "spot" => 10,
908            "linear" | "inverse" | "option" => 20,
909            _ => 10,
910        };
911
912        if orders.len() > max_batch_size {
913            return Ok(BatchResult::transport_failure(
914                EMSError::invalid_params(format!(
915                    "Batch size {} exceeds maximum limit {}",
916                    orders.len(),
917                    max_batch_size
918                )),
919                orders.len(),
920                0,
921            ));
922        }
923
924        let request = BybitBatchOrderRequest {
925            category: category.into(),
926            request: orders,
927        };
928
929        let endpoint = "/v5/order/create-batch";
930        let timestamp = self.get_timestamp();
931
932        let body = simd_json::to_string(&request).map_err(|e| {
933            EMSError::exchange_api(
934                "Bybit",
935                -1,
936                "JSON serialization failed",
937                Some(e.to_string()),
938            )
939        })?;
940
941        let signature = self.generate_signature(&timestamp, &body)?;
942
943        let response = self
944            .client
945            .post(format!("{}{endpoint}", self.base_url))
946            .header("X-BAPI-API-KEY", &*self.api_key)
947            .header("X-BAPI-SIGN", signature)
948            .header("X-BAPI-TIMESTAMP", timestamp)
949            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
950            .header("Content-Type", "application/json")
951            .body(body)
952            .send()
953            .await;
954
955        let response = match response {
956            Ok(resp) => resp,
957            Err(e) => {
958                return Ok(BatchResult::transport_failure(
959                    EMSError::connection(format!("Batch request failed: {e}")),
960                    request.request.len(),
961                    0,
962                ));
963            }
964        };
965
966        let response_text = response
967            .text()
968            .await
969            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
970
971        let mut response_json = response_text.into_bytes();
972        let api_response: BybitApiResponse<BybitBatchOrderResponse> =
973            simd_json::from_slice(&mut response_json).map_err(|e| {
974                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
975            })?;
976
977        self.process_batch_response(api_response, request.request.len())
978    }
979
980    /// Get account information including account type detection
981    /// This is critical for V5 API compliance as different account types
982    /// support different features and have different API behaviors
983    pub async fn get_account_info(&self) -> EMSResult<BybitAccountInfo> {
984        let endpoint = "/v5/account/info";
985        let timestamp = self.get_timestamp();
986        let signature = self.generate_signature(&timestamp, "")?;
987
988        let response = self
989            .client
990            .get(format!("{}{endpoint}", self.base_url))
991            .header("X-BAPI-API-KEY", &*self.api_key)
992            .header("X-BAPI-SIGN", signature)
993            .header("X-BAPI-TIMESTAMP", timestamp)
994            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
995            .send()
996            .await
997            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
998
999        let response_text = response
1000            .text()
1001            .await
1002            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
1003
1004        let mut response_json = response_text.into_bytes();
1005        let api_response: BybitApiResponse<BybitAccountInfo> =
1006            simd_json::from_slice(&mut response_json).map_err(|e| {
1007                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
1008            })?;
1009
1010        if api_response.ret_code != 0 {
1011            return Err(EMSError::exchange_api(
1012                "Bybit",
1013                api_response.ret_code,
1014                "Account info query failed",
1015                Some(api_response.ret_msg),
1016            ));
1017        }
1018
1019        api_response.result.ok_or_else(|| {
1020            EMSError::exchange_api(
1021                "Bybit",
1022                -1,
1023                "Missing result in response",
1024                Some("Empty result field"),
1025            )
1026        })
1027    }
1028
1029    /// Get wallet balance
1030    pub async fn get_wallet_balance(
1031        &self,
1032        account_type: &str,
1033        coin: Option<&str>,
1034    ) -> EMSResult<Vec<BybitWalletBalance>> {
1035        let mut endpoint = format!("/v5/account/wallet-balance?accountType={account_type}");
1036
1037        if let Some(coin) = coin {
1038            endpoint.push_str(&format!("&coin={coin}"));
1039        }
1040
1041        let timestamp = self.get_timestamp();
1042        let query_string = endpoint.split('?').nth(1).unwrap_or("");
1043        let signature = self.generate_signature(&timestamp, query_string)?;
1044
1045        let response = self
1046            .client
1047            .get(format!("{}{endpoint}", self.base_url))
1048            .header("X-BAPI-API-KEY", &*self.api_key)
1049            .header("X-BAPI-SIGN", signature)
1050            .header("X-BAPI-TIMESTAMP", timestamp)
1051            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
1052            .send()
1053            .await
1054            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
1055
1056        let response_text = response
1057            .text()
1058            .await
1059            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
1060
1061        let mut response_json = response_text.into_bytes();
1062        let api_response: BybitApiResponse<WalletBalanceResult> =
1063            simd_json::from_slice(&mut response_json).map_err(|e| {
1064                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
1065            })?;
1066
1067        if api_response.ret_code != 0 {
1068            return Err(EMSError::exchange_api(
1069                "Bybit",
1070                api_response.ret_code,
1071                "Wallet balance query failed",
1072                Some(api_response.ret_msg),
1073            ));
1074        }
1075
1076        api_response.result.map(|r| r.list).ok_or_else(|| {
1077            EMSError::exchange_api(
1078                "Bybit",
1079                -1,
1080                "Missing result in response",
1081                Some("Empty result field"),
1082            )
1083        })
1084    }
1085
1086    /// Get positions
1087    pub async fn get_positions(
1088        &self,
1089        category: &str,
1090        symbol: Option<&str>,
1091        settle_coin: Option<&str>,
1092    ) -> EMSResult<Vec<BybitPosition>> {
1093        let mut endpoint = format!("/v5/position/list?category={category}");
1094
1095        if let Some(symbol) = symbol {
1096            endpoint.push_str(&format!("&symbol={symbol}"));
1097        }
1098
1099        if let Some(settle_coin) = settle_coin {
1100            endpoint.push_str(&format!("&settleCoin={settle_coin}"));
1101        }
1102
1103        let timestamp = self.get_timestamp();
1104        let query_string = endpoint.split('?').nth(1).unwrap_or("");
1105        let signature = self.generate_signature(&timestamp, query_string)?;
1106
1107        let response = self
1108            .client
1109            .get(format!("{}{endpoint}", self.base_url))
1110            .header("X-BAPI-API-KEY", &*self.api_key)
1111            .header("X-BAPI-SIGN", signature)
1112            .header("X-BAPI-TIMESTAMP", timestamp)
1113            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
1114            .send()
1115            .await
1116            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
1117
1118        let response_text = response
1119            .text()
1120            .await
1121            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
1122
1123        let mut response_json = response_text.into_bytes();
1124        let api_response: BybitApiResponse<PositionResult> =
1125            simd_json::from_slice(&mut response_json).map_err(|e| {
1126                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
1127            })?;
1128
1129        if api_response.ret_code != 0 {
1130            return Err(EMSError::exchange_api(
1131                "Bybit",
1132                api_response.ret_code,
1133                "Position query failed",
1134                Some(api_response.ret_msg),
1135            ));
1136        }
1137
1138        api_response.result.map(|r| r.list).ok_or_else(|| {
1139            EMSError::exchange_api(
1140                "Bybit",
1141                -1,
1142                "Missing result in response",
1143                Some("Empty result field"),
1144            )
1145        })
1146    }
1147
1148    /// Cancel a single order
1149    pub async fn cancel_order(
1150        &self,
1151        category: &str,
1152        symbol: &str,
1153        order_id: Option<&str>,
1154        order_link_id: Option<&str>,
1155    ) -> EMSResult<()> {
1156        let endpoint = "/v5/order/cancel";
1157        let timestamp = self.get_timestamp();
1158
1159        let mut cancel_request = FxHashMap::default();
1160        cancel_request.insert("category", category);
1161        cancel_request.insert("symbol", symbol);
1162
1163        if let Some(order_id) = order_id {
1164            cancel_request.insert("orderId", order_id);
1165        }
1166
1167        if let Some(order_link_id) = order_link_id {
1168            cancel_request.insert("orderLinkId", order_link_id);
1169        }
1170
1171        let body = simd_json::to_string(&cancel_request).map_err(|e| {
1172            EMSError::exchange_api(
1173                "Bybit",
1174                -1,
1175                "JSON serialization failed",
1176                Some(e.to_string()),
1177            )
1178        })?;
1179
1180        let signature = self.generate_signature(&timestamp, &body)?;
1181
1182        let response = self
1183            .client
1184            .post(format!("{}{endpoint}", self.base_url))
1185            .header("X-BAPI-API-KEY", &*self.api_key)
1186            .header("X-BAPI-SIGN", signature)
1187            .header("X-BAPI-TIMESTAMP", timestamp)
1188            .header("X-BAPI-RECV-WINDOW", self.recv_window.to_string())
1189            .header("Content-Type", "application/json")
1190            .body(body)
1191            .send()
1192            .await
1193            .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
1194
1195        let response_text = response
1196            .text()
1197            .await
1198            .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
1199
1200        let mut response_json = response_text.into_bytes();
1201        let api_response: BybitApiResponse<simd_json::OwnedValue> =
1202            simd_json::from_slice(&mut response_json).map_err(|e| {
1203                EMSError::exchange_api("Bybit", -1, "JSON parsing failed", Some(e.to_string()))
1204            })?;
1205
1206        if api_response.ret_code != 0 {
1207            return Err(EMSError::exchange_api(
1208                "Bybit",
1209                api_response.ret_code,
1210                "Order cancellation failed",
1211                Some(api_response.ret_msg),
1212            ));
1213        }
1214
1215        Ok(())
1216    }
1217
1218    /// Helper: Process batch response and classify errors
1219    fn process_batch_response(
1220        &self,
1221        api_response: BybitApiResponse<BybitBatchOrderResponse>,
1222        total_orders: usize,
1223    ) -> EMSResult<BatchResult<BybitOrderResponse>> {
1224        // Check for transport-level error
1225        if api_response.ret_code != 0 {
1226            return Ok(BatchResult::transport_failure(
1227                EMSError::exchange_api(
1228                    "Bybit",
1229                    api_response.ret_code,
1230                    "Batch operation failed",
1231                    Some(api_response.ret_msg),
1232                ),
1233                total_orders,
1234                0,
1235            ));
1236        }
1237
1238        let Some(result) = api_response.result else {
1239            return Ok(BatchResult::transport_failure(
1240                EMSError::exchange_api(
1241                    "Bybit",
1242                    -1,
1243                    "Missing result in response",
1244                    Some("Empty result field"),
1245                ),
1246                total_orders,
1247                0,
1248            ));
1249        };
1250
1251        let Some(ext_info) = api_response.ret_ext_info else {
1252            return Ok(BatchResult::transport_failure(
1253                EMSError::exchange_api(
1254                    "Bybit",
1255                    -1,
1256                    "Missing ext info in response",
1257                    Some("Empty retExtInfo field"),
1258                ),
1259                total_orders,
1260                0,
1261            ));
1262        };
1263
1264        // Process individual order results
1265        let mut successful_orders = 0;
1266        let mut failed_orders = 0;
1267        let mut order_results = FxHashMap::default();
1268
1269        for (i, (order_result, error_info)) in
1270            result.list.iter().zip(ext_info.list.iter()).enumerate()
1271        {
1272            if error_info.code == 0 {
1273                successful_orders += 1;
1274                order_results.insert(
1275                    i.to_string().into(),
1276                    OrderResult::Success(order_result.clone()),
1277                );
1278            } else {
1279                failed_orders += 1;
1280                let error = EMSError::exchange_api(
1281                    "Bybit",
1282                    error_info.code,
1283                    "Individual order failed",
1284                    Some(error_info.msg.clone()),
1285                );
1286                // Create a dummy order for error reporting since we don't have the original order
1287                let dummy_order = rusty_model::Order::new(
1288                    rusty_model::venues::Venue::Bybit,
1289                    "UNKNOWN",
1290                    rusty_model::enums::OrderSide::Buy,
1291                    rusty_model::enums::OrderType::Limit,
1292                    rust_decimal::Decimal::ZERO,
1293                    None,
1294                    rusty_model::ClientId::new("unknown"),
1295                );
1296                order_results.insert(
1297                    i.to_string().into(),
1298                    OrderResult::Failed {
1299                        error,
1300                        order: Box::new(dummy_order),
1301                        is_retryable: false,
1302                    },
1303                );
1304            }
1305        }
1306
1307        if failed_orders == 0 {
1308            Ok(BatchResult::success(order_results, 0))
1309        } else if successful_orders == 0 {
1310            Ok(BatchResult::all_failed(order_results, 0))
1311        } else {
1312            Ok(BatchResult::partial_success(order_results, 0))
1313        }
1314    }
1315
1316    /// Helper: Check if error code indicates a retryable condition
1317    const fn is_retryable_error(&self, error_code: i32) -> bool {
1318        matches!(
1319            error_code,
1320            10003 | // Rate limit exceeded
1321            10016 | // Server error
1322            10018 | // System error
1323            130048 | // Connection timeout
1324            130049 // System busy
1325        )
1326    }
1327
1328    /// Helper: Generate timestamp
1329    fn get_timestamp(&self) -> String {
1330        use std::time::{SystemTime, UNIX_EPOCH};
1331        SystemTime::now()
1332            .duration_since(UNIX_EPOCH)
1333            .unwrap()
1334            .as_millis()
1335            .to_string()
1336    }
1337
1338    /// Helper: Generate HMAC-SHA256 signature
1339    fn generate_signature(&self, timestamp: &str, params: &str) -> EMSResult<String> {
1340        use hmac::{Hmac, Mac};
1341        use sha2::Sha256;
1342
1343        let message = format!("{}{}{}", timestamp, &*self.api_key, self.recv_window) + params;
1344
1345        let mut mac = Hmac::<Sha256>::new_from_slice(self.secret_key.as_bytes()).map_err(|e| {
1346            EMSError::exchange_api("Bybit", -1, "Invalid secret key", Some(e.to_string()))
1347        })?;
1348
1349        mac.update(message.as_bytes());
1350        let signature = mac.finalize().into_bytes();
1351
1352        Ok(hex::encode(signature))
1353    }
1354
1355    /// V5 Account-aware order creation with automatic account type detection
1356    pub async fn create_order_v5(
1357        &self,
1358        mut request: BybitOrderRequest,
1359        report_tx: Sender<ExecutionReport>,
1360    ) -> EMSResult<BybitOrderResponse> {
1361        // Get account info to determine capabilities
1362        let account_info = self.get_account_info().await?;
1363        let account_type = account_info.get_account_type();
1364
1365        // Validate and adjust order parameters based on account type
1366        self.validate_order_for_account_type(&mut request, account_type)?;
1367
1368        // Create the order with V5-compliant parameters
1369        self.create_order(request, report_tx).await
1370    }
1371
1372    /// Validate and adjust order parameters based on account type
1373    fn validate_order_for_account_type(
1374        &self,
1375        request: &mut BybitOrderRequest,
1376        account_type: Option<BybitAccountType>,
1377    ) -> EMSResult<()> {
1378        let Some(account_type) = account_type else {
1379            return Err(EMSError::invalid_params(
1380                "Unknown account type - cannot determine V5 capabilities",
1381            ));
1382        };
1383
1384        // UTA 2.0 specific validations and adjustments
1385        if account_type.supports_uta2_features() {
1386            // UTA 2.0: Inverse Futures no longer support hedge mode
1387            if request.category == "inverse" {
1388                request.position_idx = Some(0); // Force one-way mode
1389            }
1390
1391            // UTA 2.0: Enable unified margin trading features
1392            if request.category == "spot" && request.is_leverage.is_some() {
1393                // Unified account spot margin trading is supported
1394            }
1395        }
1396
1397        // Classic account specific validations
1398        if account_type == BybitAccountType::Classic {
1399            // Classic: Separate account handling
1400            if request.category == "spot" && request.is_leverage == Some(1) {
1401                return Err(EMSError::invalid_params(
1402                    "Classic accounts do not support unified margin trading",
1403                ));
1404            }
1405        }
1406
1407        // UTA 1.0 specific validations
1408        if matches!(
1409            account_type,
1410            BybitAccountType::Uta1 | BybitAccountType::Uta1Pro
1411        ) {
1412            // UTA 1.0: Limited unified features
1413            if request.category == "inverse" && request.position_idx.unwrap_or(0) != 0 {
1414                // UTA 1.0 supports hedge mode for inverse futures
1415            }
1416        }
1417
1418        Ok(())
1419    }
1420
1421    /// Create batch orders with account type awareness
1422    pub async fn create_batch_orders_v5(
1423        &self,
1424        category: &str,
1425        mut orders: SmallVec<[BybitOrderRequest; 10]>,
1426        report_tx: Sender<ExecutionReport>,
1427    ) -> EMSResult<BatchResult<BybitOrderResponse>> {
1428        // Get account info once for the entire batch
1429        let account_info = self.get_account_info().await?;
1430        let account_type = account_info.get_account_type();
1431
1432        // Validate and adjust all orders in the batch
1433        for order in &mut orders {
1434            self.validate_order_for_account_type(order, account_type)?;
1435        }
1436
1437        // Account type specific batch size limits
1438        let max_batch_size = if account_type.is_some_and(|t| t.supports_uta2_features()) {
1439            // UTA 2.0: Enhanced batch support including inverse contracts
1440            match category {
1441                "spot" => 10,
1442                "linear" | "inverse" | "option" => 20, // UTA 2.0 supports inverse in batch
1443                _ => 10,
1444            }
1445        } else {
1446            // Classic/UTA 1.0: Limited batch support
1447            match category {
1448                "spot" => 10,
1449                "linear" | "option" => 20, // No inverse support in batch
1450                "inverse" => {
1451                    return Err(EMSError::invalid_params(
1452                        "Batch orders for inverse contracts require UTA 2.0",
1453                    ));
1454                }
1455                _ => 10,
1456            }
1457        };
1458
1459        if orders.len() > max_batch_size {
1460            return Ok(BatchResult::transport_failure(
1461                EMSError::invalid_params(format!(
1462                    "Batch size {} exceeds limit {} for account type {:?}",
1463                    orders.len(),
1464                    max_batch_size,
1465                    account_type
1466                )),
1467                orders.len(),
1468                0,
1469            ));
1470        }
1471
1472        // Use the regular batch creation method
1473        self.create_batch_orders(category, orders, report_tx).await
1474    }
1475
1476    /// Get supported categories for the current account type
1477    pub async fn get_supported_categories(&self) -> EMSResult<Vec<SmartString>> {
1478        let account_info = self.get_account_info().await?;
1479
1480        let mut categories = vec!["spot".into(), "linear".into()];
1481
1482        // All account types support inverse and option trading
1483        categories.push("inverse".into());
1484        categories.push("option".into());
1485
1486        Ok(categories)
1487    }
1488
1489    /// Check if account supports a specific V5 feature
1490    pub async fn supports_feature(&self, feature: &str) -> EMSResult<bool> {
1491        let account_info = self.get_account_info().await?;
1492        let account_type = account_info.get_account_type();
1493
1494        let supports = match feature {
1495            "unified_margin" => account_type.is_some_and(|t| t.supports_unified_features()),
1496            "uta2_features" => account_type.is_some_and(|t| t.supports_uta2_features()),
1497            "hedge_mode" => account_type.is_some_and(|t| t.supports_hedge_mode()),
1498            "inverse_batch" => account_type.is_some_and(|t| t.supports_uta2_features()),
1499            "spot_margin" => account_type.is_some_and(|t| t.supports_unified_features()),
1500            _ => false,
1501        };
1502
1503        Ok(supports)
1504    }
1505}
1506
1507/// Helper structs for API responses
1508#[derive(Debug, Deserialize)]
1509struct WalletBalanceResult {
1510    list: Vec<BybitWalletBalance>,
1511}
1512
1513#[derive(Debug, Deserialize)]
1514struct PositionResult {
1515    list: Vec<BybitPosition>,
1516}
1517
1518// Implementation moved below to include V5 fields