1use 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
25pub mod v5_constants {
29 pub const CATEGORY_SPOT: &str = "spot";
31 pub const CATEGORY_LINEAR: &str = "linear";
33 pub const CATEGORY_INVERSE: &str = "inverse";
35 pub const CATEGORY_OPTION: &str = "option";
37
38 pub const MARKET_UNIT_BASE_COIN: &str = "baseCoin";
40 pub const MARKET_UNIT_QUOTE_COIN: &str = "quoteCoin";
42
43 pub const SLIPPAGE_TICK_SIZE: &str = "TickSize";
45 pub const SLIPPAGE_PERCENT: &str = "Percent";
47
48 pub const ORDER_FILTER_ORDER: &str = "Order";
50 pub const ORDER_FILTER_TPSL_ORDER: &str = "tpslOrder";
52 pub const ORDER_FILTER_STOP_ORDER: &str = "StopOrder";
54
55 pub const TRIGGER_BY_LAST_PRICE: &str = "LastPrice";
57 pub const TRIGGER_BY_INDEX_PRICE: &str = "IndexPrice";
59 pub const TRIGGER_BY_MARK_PRICE: &str = "MarkPrice";
61
62 pub const TRIGGER_DIRECTION_RISE: u8 = 1;
64 pub const TRIGGER_DIRECTION_FALL: u8 = 2;
66
67 pub const TPSL_MODE_FULL: &str = "Full";
69 pub const TPSL_MODE_PARTIAL: &str = "Partial";
71
72 pub const ORDER_TYPE_MARKET: &str = "Market";
74 pub const ORDER_TYPE_LIMIT: &str = "Limit";
76
77 pub const SMP_NONE: &str = "None";
79 pub const SMP_CANCEL_MAKER: &str = "CancelMaker";
81 pub const SMP_CANCEL_TAKER: &str = "CancelTaker";
83 pub const SMP_CANCEL_BOTH: &str = "CancelBoth";
85
86 pub const LEVERAGE_DISABLED: u8 = 0;
88 pub const LEVERAGE_ENABLED: u8 = 1;
90}
91
92pub mod v5_validation {
94 use rust_decimal::Decimal;
95
96 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, _ => false,
102 }
103 }
104
105 pub fn supports_unified_account(category: &str) -> bool {
107 matches!(category, "spot" | "linear" | "inverse" | "option")
108 }
109
110 pub fn supports_conditional_orders(category: &str) -> bool {
112 matches!(category, "spot" | "linear" | "inverse")
113 }
114
115 pub fn supports_options_features(category: &str) -> bool {
117 matches!(category, "option")
118 }
119}
120
121#[derive(Debug, Clone)]
123pub struct CommonOrderParams<'a> {
124 pub category: &'a str,
126 pub symbol: &'a str,
128 pub side: OrderSide,
130 pub order_type: OrderType,
132 pub quantity: Decimal,
134 pub price: Option<Decimal>,
136 pub time_in_force: Option<TimeInForce>,
138 pub client_order_id: Option<&'a str>,
140}
141
142#[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#[derive(Debug, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct BybitOrderRequest {
157 pub category: SmartString,
160 pub symbol: SmartString,
162 pub side: SmartString,
164 pub order_type: SmartString,
166 pub qty: SmartString,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub price: Option<SmartString>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub time_in_force: Option<SmartString>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub order_link_id: Option<SmartString>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub position_idx: Option<u8>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub reduce_only: Option<bool>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub close_on_trigger: Option<bool>,
190
191 #[serde(skip_serializing_if = "Option::is_none")]
195 pub is_leverage: Option<u8>,
196
197 #[serde(skip_serializing_if = "Option::is_none")]
200 pub market_unit: Option<SmartString>,
201
202 #[serde(skip_serializing_if = "Option::is_none")]
205 pub slippage_tolerance_type: Option<SmartString>,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
210 pub slippage_tolerance: Option<SmartString>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
215 pub order_filter: Option<SmartString>,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
220 pub trigger_direction: Option<u8>,
221
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub trigger_price: Option<SmartString>,
225
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub trigger_by: Option<SmartString>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
233 pub order_iv: Option<SmartString>,
234
235 #[serde(skip_serializing_if = "Option::is_none")]
238 pub take_profit: Option<SmartString>,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub stop_loss: Option<SmartString>,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub tp_trigger_by: Option<SmartString>,
247
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub sl_trigger_by: Option<SmartString>,
251
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub tpsl_mode: Option<SmartString>,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub tp_limit_price: Option<SmartString>,
259
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub sl_limit_price: Option<SmartString>,
263
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub tp_order_type: Option<SmartString>,
267
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub sl_order_type: Option<SmartString>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
275 pub smp_type: Option<SmartString>,
276
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub mmp: Option<bool>,
280}
281
282impl BybitOrderRequest {
283 pub fn from_common(params: CommonOrderParams) -> EMSResult<Self> {
286 Self::from_common_with_category(params)
287 }
288
289 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 pub fn builder() -> BybitOrderRequestBuilder {
316 BybitOrderRequestBuilder::new()
317 }
318
319 pub fn from_common_with_category(params: CommonOrderParams) -> EMSResult<Self> {
322 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 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", OrderType::ImmediateOrCancel => "Limit", OrderType::PostOnly => "Limit", };
357
358 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"), })
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 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#[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 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 pub fn category(mut self, category: &str) -> Self {
469 self.request.category = category.into();
470 self
471 }
472
473 pub fn symbol(mut self, symbol: &str) -> Self {
475 self.request.symbol = symbol.into();
476 self
477 }
478
479 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 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", OrderType::StopLimit => "Limit", OrderType::FillOrKill => "Limit",
497 OrderType::ImmediateOrCancel => "Limit",
498 OrderType::PostOnly => "Limit",
499 }
500 .into();
501 self
502 }
503
504 pub fn quantity(mut self, quantity: Decimal) -> Self {
506 self.request.qty = quantity.to_string().into();
507 self
508 }
509
510 pub fn price(mut self, price: Decimal) -> Self {
512 self.request.price = Some(price.to_string().into());
513 self
514 }
515
516 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", };
529 self.request.time_in_force = Some(tif_str.into());
530 Ok(self)
531 }
532
533 pub fn client_order_id(mut self, id: &str) -> Self {
535 self.request.order_link_id = Some(id.into());
536 self
537 }
538
539 pub fn trigger_price(mut self, price: Decimal) -> Self {
541 self.request.trigger_price = Some(price.to_string().into());
542 self
543 }
544
545 pub fn trigger_by(mut self, trigger_by: &str) -> Self {
547 self.request.trigger_by = Some(trigger_by.into());
548 self
549 }
550
551 pub const fn trigger_direction(mut self, direction: u8) -> Self {
553 self.request.trigger_direction = Some(direction);
554 self
555 }
556
557 pub fn build(self) -> EMSResult<BybitOrderRequest> {
559 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#[derive(Debug, Serialize)]
585pub struct BybitBatchOrderRequest {
586 pub category: SmartString,
588 pub request: SmallVec<[BybitOrderRequest; 10]>,
590}
591
592#[derive(Debug, Clone, Deserialize)]
594#[serde(rename_all = "camelCase")]
595pub struct BybitOrderResponse {
596 pub order_id: SmartString,
598 pub order_link_id: SmartString,
600}
601
602#[derive(Debug, Deserialize)]
604pub struct BybitBatchOrderResponse {
605 pub list: Vec<BybitOrderResponse>,
607}
608
609#[derive(Debug, Deserialize)]
611pub struct BybitPosition {
612 pub symbol: SmartString,
614 pub side: SmartString,
616 pub size: SmartString,
618 #[serde(rename = "avgPrice")]
620 pub avg_price: SmartString,
621 #[serde(rename = "markPrice")]
623 pub mark_price: SmartString,
624 #[serde(rename = "unrealisedPnl")]
626 pub unrealised_pnl: SmartString,
627 pub leverage: SmartString,
629}
630
631#[derive(Debug, Deserialize)]
633pub struct BybitCoin {
634 pub coin: SmartString,
636 #[serde(rename = "walletBalance")]
638 pub wallet_balance: SmartString,
639 #[serde(rename = "availableToWithdraw")]
641 pub available_to_withdraw: SmartString,
642 pub equity: SmartString,
644 #[serde(rename = "usdValue")]
646 pub usd_value: SmartString,
647}
648
649#[derive(Debug, Deserialize)]
651pub struct BybitWalletBalance {
652 #[serde(rename = "accountType")]
654 pub account_type: SmartString,
655 #[serde(rename = "totalEquity")]
657 pub total_equity: SmartString,
658 #[serde(rename = "totalWalletBalance")]
660 pub total_wallet_balance: SmartString,
661 #[serde(rename = "totalAvailableBalance")]
663 pub total_available_balance: SmartString,
664 pub coin: Vec<BybitCoin>,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum BybitAccountType {
671 Classic = 1,
673 Uta1 = 3,
675 Uta1Pro = 4,
677 Uta2 = 5,
679 Uta2Pro = 6,
681}
682
683impl BybitAccountType {
684 #[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 #[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 #[must_use]
708 pub const fn supports_uta2_features(self) -> bool {
709 matches!(self, Self::Uta2 | Self::Uta2Pro)
710 }
711
712 #[must_use]
714 pub const fn supports_hedge_mode(self) -> bool {
715 matches!(self, Self::Classic | Self::Uta1 | Self::Uta1Pro)
716 }
717
718 #[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#[derive(Debug, Clone, Deserialize)]
733#[serde(rename_all = "camelCase")]
734pub struct BybitAccountInfo {
735 pub unified_margin_status: i32,
737 pub account_id: SmartString,
739 pub account_type: SmartString,
741 pub uid: SmartString,
743 #[serde(skip_serializing_if = "Option::is_none")]
745 pub account_mode: Option<SmartString>,
746 #[serde(skip_serializing_if = "Option::is_none")]
748 pub master_trader_id: Option<SmartString>,
749}
750
751impl BybitAccountInfo {
752 #[must_use]
754 pub const fn get_account_type(&self) -> Option<BybitAccountType> {
755 BybitAccountType::from_status(self.unified_margin_status)
756 }
757
758 #[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 #[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#[derive(Debug, Deserialize)]
775#[serde(rename_all = "camelCase")]
776pub struct BybitApiResponse<T> {
777 pub ret_code: i32,
779 pub ret_msg: SmartString,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub result: Option<T>,
784 #[serde(skip_serializing_if = "Option::is_none")]
786 pub ret_ext_info: Option<BybitExtInfo>,
787}
788
789#[derive(Debug, Deserialize)]
791pub struct BybitExtInfo {
792 pub list: Vec<BybitErrorInfo>,
794}
795
796#[derive(Debug, Deserialize)]
798pub struct BybitErrorInfo {
799 pub code: i32,
801 pub msg: SmartString,
803}
804
805impl BybitRestClient {
806 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, }
825 }
826
827 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(×tamp, &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 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 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(×tamp, &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 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(×tamp, "")?;
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 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(×tamp, 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 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(×tamp, 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 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(×tamp, &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 fn process_batch_response(
1220 &self,
1221 api_response: BybitApiResponse<BybitBatchOrderResponse>,
1222 total_orders: usize,
1223 ) -> EMSResult<BatchResult<BybitOrderResponse>> {
1224 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 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 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 const fn is_retryable_error(&self, error_code: i32) -> bool {
1318 matches!(
1319 error_code,
1320 10003 | 10016 | 10018 | 130048 | 130049 )
1326 }
1327
1328 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 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 pub async fn create_order_v5(
1357 &self,
1358 mut request: BybitOrderRequest,
1359 report_tx: Sender<ExecutionReport>,
1360 ) -> EMSResult<BybitOrderResponse> {
1361 let account_info = self.get_account_info().await?;
1363 let account_type = account_info.get_account_type();
1364
1365 self.validate_order_for_account_type(&mut request, account_type)?;
1367
1368 self.create_order(request, report_tx).await
1370 }
1371
1372 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 if account_type.supports_uta2_features() {
1386 if request.category == "inverse" {
1388 request.position_idx = Some(0); }
1390
1391 if request.category == "spot" && request.is_leverage.is_some() {
1393 }
1395 }
1396
1397 if account_type == BybitAccountType::Classic {
1399 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 if matches!(
1409 account_type,
1410 BybitAccountType::Uta1 | BybitAccountType::Uta1Pro
1411 ) {
1412 if request.category == "inverse" && request.position_idx.unwrap_or(0) != 0 {
1414 }
1416 }
1417
1418 Ok(())
1419 }
1420
1421 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 let account_info = self.get_account_info().await?;
1430 let account_type = account_info.get_account_type();
1431
1432 for order in &mut orders {
1434 self.validate_order_for_account_type(order, account_type)?;
1435 }
1436
1437 let max_batch_size = if account_type.is_some_and(|t| t.supports_uta2_features()) {
1439 match category {
1441 "spot" => 10,
1442 "linear" | "inverse" | "option" => 20, _ => 10,
1444 }
1445 } else {
1446 match category {
1448 "spot" => 10,
1449 "linear" | "option" => 20, "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 self.create_batch_orders(category, orders, report_tx).await
1474 }
1475
1476 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 categories.push("inverse".into());
1484 categories.push("option".into());
1485
1486 Ok(categories)
1487 }
1488
1489 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#[derive(Debug, Deserialize)]
1509struct WalletBalanceResult {
1510 list: Vec<BybitWalletBalance>,
1511}
1512
1513#[derive(Debug, Deserialize)]
1514struct PositionResult {
1515 list: Vec<BybitPosition>,
1516}
1517
1518