1use crate::clock::ServerClock;
20use crate::credentials::Credentials;
21use crate::error::{Error, Result};
22use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
23use crate::feeds::{
24 DerivativesChannel, DerivativesFeed, FundingRate, LongShortRatio, MarkIndex, OpenInterest,
25};
26use crate::idempotency::ClientIdGenerator;
27use crate::normalize::{format_decimal, parse_decimal};
28use crate::options::{ExchangeOptions, MarginMode, MarketType, PositionMode, SelfTradePrevention};
29use crate::positions::{Position, PositionSide};
30use crate::signing::hmac_sha256_base64;
31use crate::symbol::Symbol;
32use crate::traits::{
33 AdvancedOrders, Derivatives, DerivativesStream, Exchange, Execution, MarketData, WsExecution,
34 WsUserData,
35};
36use crate::transport::{HttpMethod, HttpRequest, HttpTransport, WsConnection, WsTransport};
37use crate::types::{
38 Balance, OcoRequest, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker,
39 TimeInForce,
40};
41use rust_decimal::Decimal;
42use serde::Deserialize;
43use std::collections::HashMap;
44use std::fmt;
45use wickra_core::Candle;
46
47fn system_now_ms() -> i64 {
49 use std::time::{SystemTime, UNIX_EPOCH};
50 SystemTime::now()
51 .duration_since(UNIX_EPOCH)
52 .expect("system clock is before the Unix epoch")
53 .as_millis() as i64
54}
55
56fn trade_side(request: &OrderRequest) -> &'static str {
60 if request.reduce_only {
61 "close"
62 } else {
63 "open"
64 }
65}
66
67fn margin_wire(mode: MarginMode) -> &'static str {
69 match mode {
70 MarginMode::Cross => "crossed",
71 MarginMode::Isolated => "isolated",
72 }
73}
74
75pub struct Bitget {
77 http: Box<dyn HttpTransport>,
78 ws: Option<Box<dyn WsTransport>>,
79 rest_base: String,
80 market_type: MarketType,
81 margin_mode: MarginMode,
84 position_mode: PositionMode,
87 credentials: Option<Credentials>,
88 now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
89 client_ids: ClientIdGenerator,
95 clock: ServerClock,
98 connection: Option<Box<dyn WsConnection>>,
99 sub_messages: Vec<String>,
100 subscriptions: Vec<(String, Symbol)>,
101 derivatives_channels: Vec<(String, DerivativesChannel)>,
108 private_connection: Option<Box<dyn WsConnection>>,
112 user_data_active: bool,
115}
116
117impl fmt::Debug for Bitget {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.debug_struct("Bitget")
125 .field("ws", &self.ws.is_some())
126 .field("rest_base", &self.rest_base)
127 .field("market_type", &self.market_type)
128 .field("authenticated", &self.credentials.is_some())
129 .field("clock_offset_ms", &self.clock.offset_ms())
130 .field("connection", &self.connection.is_some())
131 .field("sub_messages", &self.sub_messages.len())
132 .field("subscriptions", &self.subscriptions.len())
133 .field("private_connection", &self.private_connection.is_some())
134 .field("user_data_active", &self.user_data_active)
135 .finish_non_exhaustive()
136 }
137}
138
139impl Bitget {
140 fn ensure_market_is_routed(&self) -> Result<()> {
148 super::ensure_market_is_routed("Bitget", self.market_type, super::SPOT_AND_LINEAR)
149 }
150
151 fn build(
152 http: Box<dyn HttpTransport>,
153 options: &ExchangeOptions,
154 credentials: Option<Credentials>,
155 ) -> Self {
156 Self {
157 http,
158 ws: None,
159 rest_base: "https://api.bitget.com".to_string(),
160 market_type: options.market_type,
161 margin_mode: options.margin_mode,
162 position_mode: options.position_mode,
163 credentials,
164 now_ms: Box::new(system_now_ms),
165 client_ids: ClientIdGenerator::with_seed(
166 "wbatch",
167 u64::try_from(system_now_ms()).unwrap_or(0),
168 ),
169 clock: ServerClock::new(),
170 connection: None,
171 sub_messages: Vec::new(),
172 subscriptions: Vec::new(),
173 derivatives_channels: Vec::new(),
174 private_connection: None,
175 user_data_active: false,
176 }
177 }
178
179 fn signed_now_ms(&self) -> i64 {
188 self.clock.server_time_ms((self.now_ms)())
189 }
190
191 pub fn sync_time(&mut self) -> Result<i64> {
203 let local_before = (self.now_ms)();
204 let value = self.get("/api/v2/public/time", "")?;
205 let server_ms: i64 = value
206 .get("serverTime")
207 .and_then(serde_json::Value::as_str)
208 .ok_or_else(|| Error::Deserialization("public/time: no serverTime".into()))?
209 .parse()
210 .map_err(|_| Error::Deserialization("public/time: serverTime not an integer".into()))?;
211 self.clock.sync(local_before, server_ms);
212 Ok(self.clock.offset_ms())
213 }
214
215 fn is_futures(&self) -> bool {
218 self.market_type.is_derivatives()
219 }
220
221 fn inst_type(&self) -> &'static str {
229 if self.is_futures() {
230 "USDT-FUTURES"
231 } else {
232 "SPOT"
233 }
234 }
235
236 #[must_use]
238 pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
239 Self::build(http, options, None)
240 }
241
242 #[must_use]
244 pub fn with_credentials(
245 http: Box<dyn HttpTransport>,
246 options: &ExchangeOptions,
247 credentials: Credentials,
248 ) -> Self {
249 Self::build(http, options, Some(credentials))
250 }
251
252 #[must_use]
254 pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
255 self.now_ms = now_ms;
256 self
257 }
258
259 #[must_use]
261 pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
262 self.ws = Some(ws);
263 self
264 }
265
266 #[must_use]
268 pub fn wire_symbol(symbol: &Symbol) -> String {
269 symbol.to_concatenated()
270 }
271
272 pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
277 let (path, query) = if self.is_futures() {
278 (
279 "/api/v2/mix/market/ticker",
280 format!(
281 "symbol={}&productType=USDT-FUTURES",
282 Self::wire_symbol(symbol)
283 ),
284 )
285 } else {
286 (
287 "/api/v2/spot/market/tickers",
288 format!("symbol={}", Self::wire_symbol(symbol)),
289 )
290 };
291 let data = self.get(path, &query)?;
292 let list: Vec<RawTicker> = parse_json(data)?;
293 let entry = list
294 .into_iter()
295 .next()
296 .ok_or_else(|| Error::NotFound(format!("no ticker for {symbol}")))?;
297 Ok(Ticker {
298 symbol: symbol.clone(),
299 last: parse_decimal(&entry.last_pr)?,
300 bid: parse_decimal(&entry.bid_pr)?,
301 ask: parse_decimal(&entry.ask_pr)?,
302 volume: parse_decimal(&entry.base_volume)?,
303 timestamp: entry.ts.parse().unwrap_or(0),
304 })
305 }
306
307 pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
313 let (path, extra) = if self.is_futures() {
314 ("/api/v2/mix/market/candles", "&productType=USDT-FUTURES")
315 } else {
316 ("/api/v2/spot/market/candles", "")
317 };
318 let query = format!(
319 "symbol={}&granularity={}&limit={limit}{extra}",
320 Self::wire_symbol(symbol),
321 map_granularity(interval),
322 );
323 let data = self.get(path, &query)?;
324 let rows: Vec<Vec<String>> = parse_json(data)?;
325 rows.iter().map(|row| parse_kline_row(row)).collect()
326 }
327
328 pub fn order_book(&self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
333 let (path, query) = if self.is_futures() {
334 (
335 "/api/v2/mix/market/merge-depth",
336 format!(
337 "symbol={}&productType=USDT-FUTURES&limit={depth}",
338 Self::wire_symbol(symbol)
339 ),
340 )
341 } else {
342 (
343 "/api/v2/spot/market/orderbook",
344 format!("symbol={}&limit={depth}", Self::wire_symbol(symbol)),
345 )
346 };
347 let data = self.get(path, &query)?;
348 let raw: RawDepth = parse_json(data)?;
349 Ok(OrderBookSnapshot {
350 symbol: symbol.clone(),
351 last_update_id: raw.ts.parse().unwrap_or(0),
352 bids: parse_levels(&raw.bids)?,
353 asks: parse_levels(&raw.asks)?,
354 timestamp: raw.ts.parse().unwrap_or(0),
355 })
356 }
357
358 pub fn subscribe_derivatives(
372 &mut self,
373 symbol: &Symbol,
374 channel: DerivativesChannel,
375 ) -> Result<()> {
376 if !self.is_futures() {
377 return Err(Error::unsupported_field(
378 "Bitget",
379 "a derivatives channel on a spot client",
380 "funding and mark/index exist only on the futures venue",
381 ));
382 }
383 if channel == DerivativesChannel::Liquidations {
384 return Err(Error::unsupported_field(
385 "Bitget",
386 "a liquidations channel",
387 "Bitget publishes no public stream of forced orders",
388 ));
389 }
390 let wire = Self::wire_symbol(symbol);
391 if !self
392 .derivatives_channels
393 .iter()
394 .any(|(w, c)| w == &wire && *c == channel)
395 {
396 self.derivatives_channels.push((wire, channel));
397 }
398 self.subscribe(symbol, "ticker")
399 }
400
401 pub fn open_interest(&self, symbol: &Symbol) -> Result<OpenInterest> {
409 if !self.is_futures() {
410 return Err(Error::unsupported_field(
411 "Bitget",
412 "open interest on a spot client",
413 "open interest is a futures figure",
414 ));
415 }
416 let query = format!(
417 "symbol={}&productType=USDT-FUTURES",
418 Self::wire_symbol(symbol)
419 );
420 let data = self.get("/api/v2/mix/market/open-interest", &query)?;
421 let point = data
422 .get("openInterestList")
423 .and_then(serde_json::Value::as_array)
424 .and_then(|rows| rows.first())
425 .ok_or_else(|| Error::NotFound("no open-interest data point".to_string()))?;
426 Ok(OpenInterest {
427 symbol: symbol.clone(),
428 open_interest: parse_decimal(field_str(point, "size")?)?,
429 timestamp: opt_str(&data, "ts").parse().unwrap_or(0),
430 })
431 }
432
433 pub fn long_short_ratio(&self, symbol: &Symbol) -> Result<LongShortRatio> {
443 if !self.is_futures() {
444 return Err(Error::unsupported_field(
445 "Bitget",
446 "long/short positioning on a spot client",
447 "positioning is a futures figure",
448 ));
449 }
450 let query = format!(
451 "symbol={}&productType=USDT-FUTURES&period=5m",
452 Self::wire_symbol(symbol)
453 );
454 let data = self.get("/api/v2/mix/market/account-long-short", &query)?;
455 let point = data
456 .as_array()
457 .and_then(|rows| rows.first())
458 .ok_or_else(|| Error::NotFound("no long/short data point".to_string()))?;
459 Ok(LongShortRatio {
460 symbol: symbol.clone(),
461 long_size: parse_decimal(field_str(point, "longAccountRatio")?)?,
462 short_size: parse_decimal(field_str(point, "shortAccountRatio")?)?,
463 timestamp: opt_str(point, "ts").parse().unwrap_or(0),
464 })
465 }
466
467 pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
472 self.subscribe(symbol, "trade")
473 }
474
475 pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
480 self.subscribe(symbol, "books")
481 }
482
483 pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
488 self.subscribe(symbol, "ticker")
489 }
490
491 fn subscribe(&mut self, symbol: &Symbol, channel: &str) -> Result<()> {
492 self.ensure_market_is_routed()?;
493 let wire = Self::wire_symbol(symbol);
494 if self.connection.is_none() {
495 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
496 let connection = ws.connect("wss://ws.bitget.com/v2/ws/public")?;
497 self.connection = Some(connection);
498 }
499 let message = format!(
500 r#"{{"op":"subscribe","args":[{{"instType":"{}","channel":"{channel}","instId":"{wire}"}}]}}"#,
501 self.inst_type()
502 );
503 self.connection
504 .as_mut()
505 .expect("connection just ensured")
506 .send(&message)?;
507 if !self.sub_messages.contains(&message) {
508 self.sub_messages.push(message.clone());
509 }
510 if !self.subscriptions.iter().any(|(w, _)| w == &wire) {
511 self.subscriptions.push((wire, symbol.clone()));
512 }
513 Ok(())
514 }
515
516 pub fn poll_events(&mut self) -> Vec<Event> {
518 let subscriptions: HashMap<String, Symbol> = self.subscriptions.iter().cloned().collect();
519 let resolve = |wire: &str| {
520 subscriptions
521 .get(wire)
522 .cloned()
523 .unwrap_or_else(|| Symbol::new(wire, ""))
524 };
525 let channels = self.derivatives_channels.clone();
526 let subscribed = |wire: &str, channel: DerivativesChannel| {
527 channels.iter().any(|(w, c)| w == wire && *c == channel)
528 };
529 let mut events = Vec::new();
530 if let Some(connection) = self.connection.as_mut() {
531 while let Ok(Some(frame)) = connection.recv() {
532 if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
533 events.append(&mut parsed);
534 }
535 }
536 }
537 if let Some(connection) = self.private_connection.as_mut() {
539 while let Ok(Some(frame)) = connection.recv() {
540 if let Ok(mut parsed) = parse_ws_message(&frame, &resolve, &subscribed) {
541 events.append(&mut parsed);
542 }
543 }
544 }
545 if self.user_data_active
548 && self
549 .private_connection
550 .as_ref()
551 .is_some_and(|c| !c.is_connected())
552 {
553 events.push(Event::Disconnected);
554 self.private_connection = None;
555 if self.subscribe_user_data().is_ok() {
556 events.push(Event::Reconnected);
557 }
558 }
559 let url = "wss://ws.bitget.com/v2/ws/public";
560 crate::wsutil::reconnect_if_dropped(
561 self.ws.as_deref(),
562 url,
563 &mut self.connection,
564 &self.sub_messages,
565 &mut events,
566 );
567 events
568 }
569
570 pub fn subscribe_user_data(&mut self) -> Result<()> {
586 self.ensure_market_is_routed()?;
587 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
588 "user-data stream requires credentials",
589 ))?;
590 let passphrase = creds
591 .passphrase
592 .as_deref()
593 .ok_or(Error::InvalidCredentials("Bitget requires a passphrase"))?;
594 let timestamp = self.signed_now_ms() / 1000;
596 let sign = hmac_sha256_base64(
597 creds.api_secret.as_bytes(),
598 format!("{timestamp}GET/user/verify").as_bytes(),
599 );
600 let login = format!(
601 r#"{{"op":"login","args":[{{"apiKey":"{}","passphrase":"{passphrase}","timestamp":"{timestamp}","sign":"{sign}"}}]}}"#,
602 creds.api_key
603 );
604 let inst_type = self.inst_type();
605 let subscribe = format!(
606 r#"{{"op":"subscribe","args":[{{"instType":"{inst_type}","channel":"orders","instId":"default"}},{{"instType":"{inst_type}","channel":"account","coin":"default"}}]}}"#
607 );
608 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
609 let mut connection = ws.connect("wss://ws.bitget.com/v2/ws/private")?;
610 connection.send(&login)?;
611 connection.send(&subscribe)?;
612 self.private_connection = Some(connection);
613 self.user_data_active = true;
614 Ok(())
615 }
616
617 pub fn keepalive_user_data(&mut self) -> Result<()> {
624 if let Some(connection) = self.private_connection.as_mut() {
625 connection.send("ping")?;
626 }
627 Ok(())
628 }
629
630 fn ensure_reduce_only_is_reducible(&self, request: &OrderRequest) -> Result<()> {
642 if request.reduce_only && !self.is_futures() {
643 return Err(Error::unsupported_field(
644 "Bitget",
645 "reduce_only on a spot order",
646 "spot holds balances, not positions, and has none to reduce",
647 ));
648 }
649 Ok(())
650 }
651
652 fn place_plan_order(&self, request: &OrderRequest) -> Result<Order> {
669 let stop = request
670 .stop_price
671 .ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
672 let futures = self.is_futures();
673 let mut body = serde_json::json!({
674 "symbol": Self::wire_symbol(&request.symbol),
675 "side": side_str(request.side),
676 "orderType": order_type_str(request.order_type),
677 "size": format_decimal(request.quantity),
678 "triggerPrice": format_decimal(stop),
679 "triggerType": if futures { "mark_price" } else { "fill_price" },
683 });
684 if let Some(price) = request.price {
685 body["executePrice"] = serde_json::json!(format_decimal(price));
686 }
687 if let Some(id) = &request.client_order_id {
688 body["clientOid"] = serde_json::json!(id.clone());
689 }
690 let path = if futures {
691 body["productType"] = serde_json::json!("USDT-FUTURES");
692 body["marginCoin"] = serde_json::json!("USDT");
693 body["marginMode"] = serde_json::json!(margin_wire(self.margin_mode));
694 body["planType"] = serde_json::json!("normal_plan");
698 if self.position_mode == PositionMode::Hedge {
699 body["tradeSide"] = serde_json::json!(trade_side(request));
700 } else if request.reduce_only {
701 body["reduceOnly"] = serde_json::json!("YES");
702 }
703 "/api/v2/mix/order/place-plan-order"
704 } else {
705 "/api/v2/spot/trade/place-plan-order"
706 };
707 let data = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
708 let placed: PlaceResult = parse_json(data)?;
709 Ok(Order {
710 id: placed.order_id,
711 client_order_id: request.client_order_id.clone(),
712 symbol: request.symbol.clone(),
713 side: request.side,
714 order_type: request.order_type,
715 status: OrderStatus::New,
716 quantity: request.quantity,
717 filled_quantity: Decimal::ZERO,
718 price: request.price,
719 average_price: None,
720 })
721 }
722
723 pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
724 self.ensure_reduce_only_is_reducible(request)?;
725 request.validate()?;
726 if request.order_type.is_trigger() {
728 return self.place_plan_order(request);
729 }
730 let force = force_for(request)?;
731 let mut body = serde_json::json!({
732 "symbol": Self::wire_symbol(&request.symbol),
733 "side": side_str(request.side),
734 "orderType": order_type_str(request.order_type),
735 "force": force,
736 "size": format_decimal(request.quantity),
737 });
738 if let Some(price) = request.price {
739 body["price"] = serde_json::json!(format_decimal(price));
740 }
741 if let Some(id) = &request.client_order_id {
742 body["clientOid"] = serde_json::json!(id.clone());
743 }
744 if let Some(mode) = stp_mode_str(request.stp) {
745 body["stpMode"] = serde_json::json!(mode);
746 }
747 let path = if self.is_futures() {
748 body["productType"] = serde_json::json!("USDT-FUTURES");
749 body["marginMode"] = serde_json::json!(margin_wire(self.margin_mode));
750 body["marginCoin"] = serde_json::json!("USDT");
751 if self.position_mode == PositionMode::Hedge {
752 body["tradeSide"] = serde_json::json!(trade_side(request));
757 } else if request.reduce_only {
758 body["reduceOnly"] = serde_json::json!("YES");
759 }
760 "/api/v2/mix/order/place-order"
761 } else {
762 "/api/v2/spot/trade/place-order"
763 };
764 let data = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
765 let placed: PlaceResult = parse_json(data)?;
766 Ok(Order {
767 id: placed.order_id,
768 client_order_id: (!placed.client_oid.is_empty()).then_some(placed.client_oid),
769 symbol: request.symbol.clone(),
770 side: request.side,
771 order_type: request.order_type,
772 status: OrderStatus::New,
773 quantity: request.quantity,
774 filled_quantity: Decimal::ZERO,
775 price: request.price,
776 average_price: None,
777 })
778 }
779
780 pub fn cancel_order(&self, symbol: &Symbol, order_id: &str) -> Result<()> {
785 if self.is_futures() {
786 let body = serde_json::json!({
787 "symbol": Self::wire_symbol(symbol),
788 "productType": "USDT-FUTURES",
789 "marginCoin": "USDT",
790 "orderId": order_id,
791 });
792 self.signed_request(
793 HttpMethod::Post,
794 "/api/v2/mix/order/cancel-order",
795 "",
796 &body.to_string(),
797 )?;
798 return Ok(());
799 }
800 let body = serde_json::json!({
801 "symbol": Self::wire_symbol(symbol),
802 "orderId": order_id,
803 });
804 self.signed_request(
805 HttpMethod::Post,
806 "/api/v2/spot/trade/cancel-order",
807 "",
808 &body.to_string(),
809 )?;
810 Ok(())
811 }
812
813 pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
818 if self.is_futures() {
819 let query = format!(
820 "symbol={}&productType=USDT-FUTURES&orderId={order_id}",
821 Self::wire_symbol(symbol)
822 );
823 let data =
825 self.signed_request(HttpMethod::Get, "/api/v2/mix/order/detail", &query, "")?;
826 let raw: RawOrder = parse_json(data)?;
827 return order_from_raw(symbol.clone(), &raw);
828 }
829 let query = format!("orderId={order_id}");
830 let data =
831 self.signed_request(HttpMethod::Get, "/api/v2/spot/trade/orderInfo", &query, "")?;
832 let list: Vec<RawOrder> = parse_json(data)?;
833 let raw = list
834 .into_iter()
835 .next()
836 .ok_or_else(|| Error::NotFound(format!("order {order_id}")))?;
837 order_from_raw(symbol.clone(), &raw)
838 }
839
840 pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
845 if self.is_futures() {
846 let mut query = "productType=USDT-FUTURES".to_string();
847 if let Some(s) = symbol {
848 query.push_str("&symbol=");
849 query.push_str(&Self::wire_symbol(s));
850 }
851 let data = self.signed_request(
852 HttpMethod::Get,
853 "/api/v2/mix/order/orders-pending",
854 &query,
855 "",
856 )?;
857 let pending: RawMixPending = parse_json(data)?;
859 return pending
860 .entrusted_list
861 .iter()
862 .map(|raw| {
863 let sym = symbol
864 .cloned()
865 .unwrap_or_else(|| split_wire_symbol(&raw.symbol));
866 order_from_raw(sym, raw)
867 })
868 .collect();
869 }
870 let query = match symbol {
871 Some(s) => format!("symbol={}", Self::wire_symbol(s)),
872 None => String::new(),
873 };
874 let data = self.signed_request(
875 HttpMethod::Get,
876 "/api/v2/spot/trade/unfilled-orders",
877 &query,
878 "",
879 )?;
880 let list: Vec<RawOrder> = parse_json(data)?;
881 list.iter()
882 .map(|raw| {
883 let sym = symbol
884 .cloned()
885 .unwrap_or_else(|| split_wire_symbol(&raw.symbol));
886 order_from_raw(sym, raw)
887 })
888 .collect()
889 }
890
891 pub fn balances(&self) -> Result<Vec<Balance>> {
896 if self.is_futures() {
897 let data = self.signed_request(
898 HttpMethod::Get,
899 "/api/v2/mix/account/accounts",
900 "productType=USDT-FUTURES",
901 "",
902 )?;
903 let list: Vec<RawMixAccount> = parse_json(data)?;
904 return Ok(list
905 .iter()
906 .map(|a| Balance {
907 asset: a.margin_coin.clone(),
908 free: dec_or_zero(&a.available),
909 locked: dec_or_zero(&a.locked),
910 })
911 .collect());
912 }
913 let data = self.signed_request(HttpMethod::Get, "/api/v2/spot/account/assets", "", "")?;
914 let list: Vec<RawAsset> = parse_json(data)?;
915 Ok(list
916 .iter()
917 .map(|a| Balance {
918 asset: a.coin.clone(),
919 free: dec_or_zero(&a.available),
920 locked: dec_or_zero(&a.frozen),
921 })
922 .collect())
923 }
924
925 fn get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
926 self.ensure_market_is_routed()?;
927 let url = format!("{}{path}?{query}", self.rest_base);
928 let response = self.http.execute(&HttpRequest::get(url))?;
929 unwrap_envelope(&response.body).map_err(|e| {
930 super::attribute_to_http_status(&response, e).with_retry_after(response.retry_after())
931 })
932 }
933
934 fn batch_client_oid(&self, request: &OrderRequest) -> String {
942 request
943 .client_order_id
944 .clone()
945 .unwrap_or_else(|| self.client_ids.next_id())
946 }
947
948 fn signed_request(
951 &self,
952 method: HttpMethod,
953 path: &str,
954 query: &str,
955 body: &str,
956 ) -> Result<serde_json::Value> {
957 self.ensure_market_is_routed()?;
958 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
959 "signed endpoint requires credentials",
960 ))?;
961 let passphrase = creds
962 .passphrase
963 .as_deref()
964 .ok_or(Error::InvalidCredentials("Bitget requires a passphrase"))?;
965 let timestamp = self.signed_now_ms().to_string();
966 let request_path = if query.is_empty() {
967 path.to_string()
968 } else {
969 format!("{path}?{query}")
970 };
971 let prehash = format!("{timestamp}{}{request_path}{body}", method.as_str());
972 let signature = hmac_sha256_base64(creds.api_secret.as_bytes(), prehash.as_bytes());
973 let url = format!("{}{request_path}", self.rest_base);
974 let mut request = HttpRequest::new(method, url)
975 .with_header("ACCESS-KEY", creds.api_key.clone())
976 .with_header("ACCESS-SIGN", signature)
977 .with_header("ACCESS-TIMESTAMP", timestamp)
978 .with_header("ACCESS-PASSPHRASE", passphrase.to_string())
979 .with_header("locale", "en-US");
980 if !body.is_empty() {
981 request = request
982 .with_header("Content-Type", "application/json")
983 .with_body(body.to_string());
984 }
985 let response = self.http.execute(&request)?;
986 unwrap_envelope(&response.body).map_err(|e| {
987 super::attribute_to_http_status(&response, e).with_retry_after(response.retry_after())
988 })
989 }
990}
991
992fn map_granularity(interval: &str) -> String {
994 match interval {
995 "1m" => "1min",
996 "3m" => "3min",
997 "5m" => "5min",
998 "15m" => "15min",
999 "30m" => "30min",
1000 "1d" => "1day",
1001 "1w" => "1week",
1002 other => other,
1003 }
1004 .to_string()
1005}
1006
1007fn unwrap_envelope(body: &str) -> Result<serde_json::Value> {
1008 let envelope: Envelope =
1009 serde_json::from_str(body).map_err(|e| Error::Deserialization(e.to_string()))?;
1010 if envelope.code != "00000" {
1011 return Err(map_error(&envelope.code, &envelope.msg));
1012 }
1013 Ok(envelope.data)
1014}
1015
1016fn parse_json<T: for<'de> Deserialize<'de>>(value: serde_json::Value) -> Result<T> {
1017 serde_json::from_value(value).map_err(|e| Error::Deserialization(e.to_string()))
1018}
1019
1020fn map_error(code: &str, msg: &str) -> Error {
1021 match code {
1022 "429" | "30007" => Error::RateLimited { retry_after: None },
1023 "40009" | "40012" | "40037" => Error::Auth(msg.to_string()),
1024 "43012" | "43011" => Error::InsufficientBalance,
1025 "40034" | "43001" => Error::NotFound(msg.to_string()),
1026 other => Error::Exchange {
1027 code: other.to_string(),
1028 message: msg.to_string(),
1029 },
1030 }
1031}
1032
1033fn side_str(side: OrderSide) -> &'static str {
1034 match side {
1035 OrderSide::Buy => "buy",
1036 OrderSide::Sell => "sell",
1037 }
1038}
1039
1040fn order_type_str(order_type: OrderType) -> &'static str {
1041 match order_type {
1042 OrderType::Market | OrderType::StopMarket => "market",
1043 OrderType::Limit | OrderType::StopLimit => "limit",
1044 }
1045}
1046
1047fn force_for(request: &OrderRequest) -> Result<&'static str> {
1052 match (request.post_only, request.time_in_force) {
1053 (true, TimeInForce::Gtc) => Ok("post_only"),
1054 (true, _) => Err(Error::unsupported_field(
1055 "Bitget",
1056 "post_only together with a non-GTC time-in-force",
1057 "`post_only` is one of the values Bitget's `force` field takes",
1058 )),
1059 (false, tif) => Ok(force_str(tif)),
1060 }
1061}
1062
1063fn force_str(tif: TimeInForce) -> &'static str {
1064 match tif {
1065 TimeInForce::Gtc => "gtc",
1066 TimeInForce::Ioc => "ioc",
1067 TimeInForce::Fok => "fok",
1068 }
1069}
1070
1071fn stp_mode_str(stp: SelfTradePrevention) -> Option<&'static str> {
1073 match stp {
1074 SelfTradePrevention::None => None,
1075 SelfTradePrevention::ExpireMaker => Some("cancel_maker"),
1076 SelfTradePrevention::ExpireTaker => Some("cancel_taker"),
1077 SelfTradePrevention::ExpireBoth => Some("cancel_both"),
1078 }
1079}
1080
1081fn parse_side(raw: &str) -> Result<OrderSide> {
1082 match raw {
1083 "buy" => Ok(OrderSide::Buy),
1084 "sell" => Ok(OrderSide::Sell),
1085 other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
1086 }
1087}
1088
1089fn parse_order_type(raw: &str) -> Result<OrderType> {
1090 match raw {
1091 "market" => Ok(OrderType::Market),
1092 "limit" => Ok(OrderType::Limit),
1093 other => Err(Error::Deserialization(format!(
1094 "unknown order type {other:?}"
1095 ))),
1096 }
1097}
1098
1099fn parse_status(raw: &str) -> Result<OrderStatus> {
1100 match raw {
1101 "live" | "new" | "init" => Ok(OrderStatus::New),
1102 "partially_filled" => Ok(OrderStatus::PartiallyFilled),
1103 "filled" | "full_fill" => Ok(OrderStatus::Filled),
1104 "cancelled" | "canceled" => Ok(OrderStatus::Canceled),
1105 other => Err(Error::Deserialization(format!("unknown status {other:?}"))),
1106 }
1107}
1108
1109fn dec_or_zero(raw: &str) -> Decimal {
1110 crate::normalize::parse_opt_decimal(Some(raw))
1111 .ok()
1112 .flatten()
1113 .unwrap_or(Decimal::ZERO)
1114}
1115
1116fn nonzero_decimal(raw: &str) -> Option<Decimal> {
1117 crate::normalize::parse_opt_decimal(Some(raw))
1118 .ok()
1119 .flatten()
1120 .filter(|d| *d > Decimal::ZERO)
1121}
1122
1123const KNOWN_QUOTES: &[&str] = &["USDT", "USDC", "EUR", "BTC", "ETH", "USD"];
1124
1125fn split_wire_symbol(wire: &str) -> Symbol {
1126 for quote in KNOWN_QUOTES {
1127 if let Some(base) = wire.strip_suffix(quote) {
1128 if !base.is_empty() {
1129 return Symbol::new(base, *quote);
1130 }
1131 }
1132 }
1133 Symbol::new(wire, "")
1134}
1135
1136fn parse_levels(levels: &[[String; 2]]) -> Result<Vec<BookLevel>> {
1137 levels
1138 .iter()
1139 .map(|[price, qty]| {
1140 Ok(BookLevel {
1141 price: parse_decimal(price)?,
1142 quantity: parse_decimal(qty)?,
1143 })
1144 })
1145 .collect()
1146}
1147
1148fn parse_kline_row(row: &[String]) -> Result<Candle> {
1149 if row.len() < 6 {
1151 return Err(Error::Deserialization("kline row too short".to_string()));
1152 }
1153 let ts = row[0]
1154 .parse::<i64>()
1155 .map_err(|e| Error::Deserialization(format!("kline ts not an integer: {e}")))?;
1156 let f = |i: usize| -> Result<f64> {
1157 row[i]
1158 .parse::<f64>()
1159 .map_err(|e| Error::Deserialization(format!("kline field not a number: {e}")))
1160 };
1161 Candle::new(f(1)?, f(2)?, f(3)?, f(4)?, f(5)?, ts)
1162 .map_err(|e| Error::Deserialization(e.to_string()))
1163}
1164
1165fn order_from_raw(symbol: Symbol, raw: &RawOrder) -> Result<Order> {
1166 Ok(Order {
1167 id: raw.order_id.clone(),
1168 client_order_id: (!raw.client_oid.is_empty()).then(|| raw.client_oid.clone()),
1169 symbol,
1170 side: parse_side(&raw.side)?,
1171 order_type: parse_order_type(&raw.order_type)?,
1172 status: parse_status(&raw.status)?,
1173 quantity: parse_decimal(&raw.size)?,
1174 filled_quantity: dec_or_zero(&raw.base_volume),
1175 price: nonzero_decimal(&raw.price),
1176 average_price: nonzero_decimal(&raw.price_avg),
1177 })
1178}
1179
1180fn field_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
1181 value
1182 .get(key)
1183 .and_then(serde_json::Value::as_str)
1184 .ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
1185}
1186
1187fn opt_str<'a>(value: &'a serde_json::Value, key: &str) -> &'a str {
1188 value
1189 .get(key)
1190 .and_then(serde_json::Value::as_str)
1191 .unwrap_or("")
1192}
1193
1194fn parse_ws_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
1195 let array = value
1196 .and_then(serde_json::Value::as_array)
1197 .ok_or_else(|| Error::Deserialization("missing depth levels".to_string()))?;
1198 array
1199 .iter()
1200 .map(|level| {
1201 let pair = level
1202 .as_array()
1203 .ok_or_else(|| Error::Deserialization("depth level not an array".to_string()))?;
1204 let price = parse_decimal(
1205 pair.first()
1206 .and_then(serde_json::Value::as_str)
1207 .ok_or_else(|| Error::Deserialization("depth price missing".to_string()))?,
1208 )?;
1209 let quantity =
1210 parse_decimal(pair.get(1).and_then(serde_json::Value::as_str).ok_or_else(
1211 || Error::Deserialization("depth quantity missing".to_string()),
1212 )?)?;
1213 Ok(BookLevel { price, quantity })
1214 })
1215 .collect()
1216}
1217
1218fn parse_ws_message(
1219 text: &str,
1220 resolve: &impl Fn(&str) -> Symbol,
1221 subscribed: &impl Fn(&str, DerivativesChannel) -> bool,
1222) -> Result<Vec<Event>> {
1223 let value: serde_json::Value =
1224 serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
1225 let arg = value.get("arg");
1226 let Some(channel) = arg
1227 .and_then(|a| a.get("channel"))
1228 .and_then(serde_json::Value::as_str)
1229 else {
1230 return Ok(Vec::new());
1231 };
1232 let wire = arg
1233 .and_then(|a| a.get("instId"))
1234 .and_then(serde_json::Value::as_str)
1235 .unwrap_or("");
1236 let symbol = resolve(wire);
1237 let empty = Vec::new();
1238 let data = value
1239 .get("data")
1240 .and_then(serde_json::Value::as_array)
1241 .unwrap_or(&empty);
1242
1243 if channel == "trade" {
1244 data.iter()
1245 .map(|t| {
1246 Ok(Event::Trade(TradePrint {
1247 symbol: symbol.clone(),
1248 price: parse_decimal(field_str(t, "price")?)?,
1249 quantity: parse_decimal(field_str(t, "size")?)?,
1250 aggressor: parse_side(field_str(t, "side")?)?,
1251 timestamp: opt_str(t, "ts").parse().unwrap_or(0),
1252 }))
1253 })
1254 .collect()
1255 } else if channel == "ticker" {
1256 let mut out = Vec::new();
1262 for t in data {
1263 let wire = opt_str(t, "instId");
1264 let timestamp = opt_str(t, "ts").parse().unwrap_or(0);
1265 out.push(Event::Ticker(Ticker {
1266 symbol: symbol.clone(),
1267 last: parse_decimal(field_str(t, "lastPr")?)?,
1268 bid: dec_or_zero(opt_str(t, "bidPr")),
1269 ask: dec_or_zero(opt_str(t, "askPr")),
1270 volume: dec_or_zero(opt_str(t, "baseVolume")),
1271 timestamp,
1272 }));
1273 let mark = parse_decimal(opt_str(t, "markPrice")).ok();
1274 if subscribed(wire, DerivativesChannel::Funding) {
1275 if let (Some(rate), Some(mark_price)) =
1276 (parse_decimal(opt_str(t, "fundingRate")).ok(), mark)
1277 {
1278 out.push(Event::Derivatives(DerivativesFeed::Funding(FundingRate {
1279 symbol: symbol.clone(),
1280 rate,
1281 mark_price,
1282 timestamp,
1283 })));
1284 }
1285 }
1286 if subscribed(wire, DerivativesChannel::MarkIndex) {
1287 if let (Some(mark_price), Some(index_price)) =
1288 (mark, parse_decimal(opt_str(t, "indexPrice")).ok())
1289 {
1290 out.push(Event::Derivatives(DerivativesFeed::MarkIndex(MarkIndex {
1291 symbol: symbol.clone(),
1292 mark_price,
1293 index_price,
1294 timestamp,
1295 })));
1296 }
1297 }
1298 }
1299 Ok(out)
1300 } else if channel == "books" {
1301 let action = value.get("action").and_then(serde_json::Value::as_str);
1302 data.iter()
1303 .map(|b| {
1304 let update_id = opt_str(b, "ts").parse().unwrap_or(0);
1305 let bids = parse_ws_levels(b.get("bids"))?;
1306 let asks = parse_ws_levels(b.get("asks"))?;
1307 if action == Some("snapshot") {
1308 Ok(Event::BookSnapshot(OrderBookSnapshot {
1309 symbol: symbol.clone(),
1310 last_update_id: update_id,
1311 bids,
1312 asks,
1313 timestamp: i64::try_from(update_id).unwrap_or(0),
1314 }))
1315 } else {
1316 Ok(Event::BookDelta(BookDelta {
1317 symbol: symbol.clone(),
1318 first_update_id: update_id,
1319 final_update_id: update_id,
1320 bids,
1321 asks,
1322 timestamp: i64::try_from(update_id).unwrap_or(0),
1323 }))
1324 }
1325 })
1326 .collect()
1327 } else if channel == "orders" {
1328 data.iter()
1331 .map(|raw| {
1332 let order: RawOrder = serde_json::from_value(raw.clone())
1333 .map_err(|e| Error::Deserialization(e.to_string()))?;
1334 let order_symbol = split_wire_symbol(&order.symbol);
1335 Ok(Event::OrderUpdate(order_from_raw(order_symbol, &order)?))
1336 })
1337 .collect()
1338 } else if channel == "account" {
1339 let balances = data
1342 .iter()
1343 .map(|raw| {
1344 let asset: RawAsset = serde_json::from_value(raw.clone())
1345 .map_err(|e| Error::Deserialization(e.to_string()))?;
1346 Ok(Balance {
1347 asset: asset.coin,
1348 free: dec_or_zero(&asset.available),
1349 locked: dec_or_zero(&asset.frozen),
1350 })
1351 })
1352 .collect::<Result<Vec<_>>>()?;
1353 Ok(vec![Event::BalanceUpdate(balances)])
1354 } else {
1355 Ok(Vec::new())
1356 }
1357}
1358
1359#[derive(Deserialize)]
1360struct Envelope {
1361 code: String,
1362 #[serde(default)]
1363 msg: String,
1364 #[serde(default)]
1365 data: serde_json::Value,
1366}
1367
1368#[derive(Deserialize)]
1369struct RawTicker {
1370 #[serde(rename = "lastPr")]
1371 last_pr: String,
1372 #[serde(rename = "bidPr")]
1373 bid_pr: String,
1374 #[serde(rename = "askPr")]
1375 ask_pr: String,
1376 #[serde(rename = "baseVolume")]
1377 base_volume: String,
1378 #[serde(default)]
1380 ts: String,
1381}
1382
1383#[derive(Deserialize)]
1384struct RawDepth {
1385 bids: Vec<[String; 2]>,
1386 asks: Vec<[String; 2]>,
1387 #[serde(default)]
1388 ts: String,
1389}
1390
1391#[derive(Deserialize)]
1392struct PlaceResult {
1393 #[serde(rename = "orderId", default)]
1394 order_id: String,
1395 #[serde(rename = "clientOid", default)]
1396 client_oid: String,
1397}
1398
1399#[derive(Deserialize)]
1400struct RawOrder {
1401 #[serde(default, alias = "instId")]
1403 symbol: String,
1404 #[serde(rename = "orderId")]
1405 order_id: String,
1406 #[serde(rename = "clientOid", default)]
1407 client_oid: String,
1408 side: String,
1409 #[serde(rename = "orderType")]
1410 order_type: String,
1411 #[serde(alias = "state")]
1413 status: String,
1414 size: String,
1415 #[serde(rename = "baseVolume", default)]
1416 base_volume: String,
1417 #[serde(default)]
1418 price: String,
1419 #[serde(rename = "priceAvg", default)]
1420 price_avg: String,
1421}
1422
1423#[derive(Deserialize)]
1424struct RawAsset {
1425 coin: String,
1426 #[serde(default)]
1427 available: String,
1428 #[serde(default)]
1429 frozen: String,
1430}
1431
1432#[derive(Deserialize)]
1434struct RawMixPending {
1435 #[serde(rename = "entrustedList", default)]
1436 entrusted_list: Vec<RawOrder>,
1437}
1438
1439impl MarketData for Bitget {
1440 fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
1441 Bitget::ticker(self, symbol)
1442 }
1443 fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
1444 Bitget::klines(self, symbol, interval, limit)
1445 }
1446 fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
1447 Bitget::order_book(self, symbol, depth)
1448 }
1449 fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
1450 Bitget::subscribe_trades(self, symbol)
1451 }
1452 fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
1453 Bitget::subscribe_book(self, symbol)
1454 }
1455 fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
1456 Bitget::subscribe_ticker(self, symbol)
1457 }
1458 fn poll_events(&mut self) -> Vec<Event> {
1459 Bitget::poll_events(self)
1460 }
1461}
1462
1463impl Execution for Bitget {
1464 fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
1465 Bitget::place_order(self, request)
1466 }
1467 fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
1468 Bitget::cancel_order(self, symbol, order_id)
1469 }
1470 fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
1471 Bitget::query_order(self, symbol, order_id)
1472 }
1473 fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
1474 Bitget::open_orders(self, symbol)
1475 }
1476 fn balances(&mut self) -> Result<Vec<Balance>> {
1477 Bitget::balances(self)
1478 }
1479}
1480
1481impl Bitget {
1482 pub fn positions(&self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1487 let data = self.signed_request(
1488 HttpMethod::Get,
1489 "/api/v2/mix/position/all-position",
1490 "productType=USDT-FUTURES&marginCoin=USDT",
1491 "",
1492 )?;
1493 let list: Vec<RawBitgetPosition> = parse_json(data)?;
1494 list.iter()
1495 .filter(|p| symbol.is_none_or(|s| p.symbol == Self::wire_symbol(s)))
1496 .filter_map(parse_bitget_position)
1497 .collect()
1498 }
1499
1500 pub fn set_leverage(&self, symbol: &Symbol, leverage: u32) -> Result<()> {
1505 let body = serde_json::json!({
1506 "symbol": Self::wire_symbol(symbol),
1507 "productType": "USDT-FUTURES",
1508 "marginCoin": "USDT",
1509 "leverage": leverage.to_string(),
1510 });
1511 self.signed_request(
1512 HttpMethod::Post,
1513 "/api/v2/mix/account/set-leverage",
1514 "",
1515 &body.to_string(),
1516 )?;
1517 Ok(())
1518 }
1519
1520 pub fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1525 let margin = margin_wire(mode);
1526 let body = serde_json::json!({
1527 "symbol": Self::wire_symbol(symbol),
1528 "productType": "USDT-FUTURES",
1529 "marginCoin": "USDT",
1530 "marginMode": margin,
1531 });
1532 self.signed_request(
1533 HttpMethod::Post,
1534 "/api/v2/mix/account/set-margin-mode",
1535 "",
1536 &body.to_string(),
1537 )?;
1538 self.margin_mode = mode;
1542 Ok(())
1543 }
1544
1545 pub fn close_position(&self, symbol: &Symbol) -> Result<Order> {
1551 let position = self
1552 .positions(Some(symbol))?
1553 .into_iter()
1554 .find(|p| &p.symbol == symbol)
1555 .ok_or_else(|| Error::NotFound(format!("no open position for {symbol}")))?;
1556 let request = match position.side {
1557 PositionSide::Long => OrderRequest::market_sell(symbol.clone(), position.quantity),
1558 PositionSide::Short => OrderRequest::market_buy(symbol.clone(), position.quantity),
1559 }
1560 .reduce_only();
1561 self.place_order(&request)
1562 }
1563
1564 pub fn place_batch(&self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1574 if requests.iter().any(|r| r.order_type.is_trigger()) {
1575 return Err(Error::unsupported_trigger("Bitget"));
1576 }
1577 for request in requests {
1578 self.ensure_reduce_only_is_reducible(request)?;
1579 }
1580 if requests.is_empty() {
1581 return Ok(Vec::new());
1582 }
1583 let wire = Self::wire_symbol(&requests[0].symbol);
1584 let forces = requests.iter().map(force_for).collect::<Result<Vec<_>>>()?;
1587 let order_list: Vec<serde_json::Value> = requests
1588 .iter()
1589 .zip(&forces)
1590 .map(|(r, force)| {
1591 let coid = self.batch_client_oid(r);
1592 let mut o = serde_json::json!({
1593 "side": side_str(r.side),
1594 "orderType": order_type_str(r.order_type),
1595 "force": force,
1596 "size": format_decimal(r.quantity),
1597 "clientOid": coid,
1598 });
1599 if let Some(mode) = stp_mode_str(r.stp) {
1600 o["stpMode"] = serde_json::json!(mode);
1601 }
1602 if let Some(price) = r.price {
1603 o["price"] = serde_json::json!(format_decimal(price));
1604 }
1605 if self.is_futures() {
1606 if self.position_mode == PositionMode::Hedge {
1610 o["tradeSide"] = serde_json::json!(trade_side(r));
1611 } else if r.reduce_only {
1612 o["reduceOnly"] = serde_json::json!("YES");
1613 }
1614 }
1615 o
1616 })
1617 .collect();
1618 let (path, body) = if self.is_futures() {
1619 (
1620 "/api/v2/mix/order/batch-place-order",
1621 serde_json::json!({
1622 "symbol": wire,
1623 "productType": "USDT-FUTURES",
1624 "marginMode": margin_wire(self.margin_mode),
1625 "marginCoin": "USDT",
1626 "orderList": order_list,
1627 }),
1628 )
1629 } else {
1630 (
1631 "/api/v2/spot/trade/batch-orders",
1632 serde_json::json!({ "symbol": wire, "orderList": order_list }),
1633 )
1634 };
1635 let data = self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
1636 let result: BatchOrdersResult = parse_json(data)?;
1637 let ok_map: HashMap<String, String> = result
1638 .success_list
1639 .into_iter()
1640 .map(|s| (s.client_oid, s.order_id))
1641 .collect();
1642 let fail_map: HashMap<String, String> = result
1643 .failure_list
1644 .into_iter()
1645 .map(|f| (f.client_oid, f.error_msg))
1646 .collect();
1647 Ok(requests
1648 .iter()
1649 .map(|req| {
1650 let coid = self.batch_client_oid(req);
1651 if let Some(order_id) = ok_map.get(&coid) {
1652 Ok(Order {
1653 id: order_id.clone(),
1654 client_order_id: req.client_order_id.clone(),
1655 symbol: req.symbol.clone(),
1656 side: req.side,
1657 order_type: req.order_type,
1658 status: OrderStatus::New,
1659 quantity: req.quantity,
1660 filled_quantity: Decimal::ZERO,
1661 price: req.price,
1662 average_price: None,
1663 })
1664 } else {
1665 Err(Error::OrderRejected {
1666 code: "batch".to_string(),
1667 message: fail_map
1668 .get(&coid)
1669 .cloned()
1670 .unwrap_or_else(|| "order rejected in batch".to_string()),
1671 })
1672 }
1673 })
1674 .collect())
1675 }
1676
1677 pub fn cancel_batch(&self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1683 let wire = Self::wire_symbol(symbol);
1684 let id_list: Vec<serde_json::Value> = order_ids
1685 .iter()
1686 .map(|id| serde_json::json!({ "orderId": id }))
1687 .collect();
1688 let (path, body) = if self.is_futures() {
1689 (
1690 "/api/v2/mix/order/batch-cancel-orders",
1691 serde_json::json!({
1692 "symbol": wire,
1693 "productType": "USDT-FUTURES",
1694 "marginCoin": "USDT",
1695 "orderIdList": id_list,
1696 }),
1697 )
1698 } else {
1699 (
1700 "/api/v2/spot/trade/batch-cancel-order",
1701 serde_json::json!({ "symbol": wire, "orderList": id_list }),
1702 )
1703 };
1704 self.signed_request(HttpMethod::Post, path, "", &body.to_string())?;
1705 Ok(())
1706 }
1707}
1708
1709impl AdvancedOrders for Bitget {
1710 fn amend_order(
1713 &mut self,
1714 _symbol: &Symbol,
1715 _order_id: &str,
1716 _new_price: Option<Decimal>,
1717 _new_quantity: Option<Decimal>,
1718 ) -> Result<Order> {
1719 Err(Error::Exchange {
1720 code: "unsupported".to_string(),
1721 message: "Bitget has no in-place amend; cancel and re-place the order".to_string(),
1722 })
1723 }
1724 fn place_batch(&mut self, requests: &[OrderRequest]) -> Result<Vec<Result<Order>>> {
1725 Bitget::place_batch(self, requests)
1726 }
1727 fn cancel_batch(&mut self, symbol: &Symbol, order_ids: &[String]) -> Result<()> {
1728 Bitget::cancel_batch(self, symbol, order_ids)
1729 }
1730 fn place_oco(&mut self, _request: &OcoRequest) -> Result<Vec<Order>> {
1733 Err(Error::Exchange {
1734 code: "unsupported".to_string(),
1735 message: "Bitget has no OCO order-list; use plan/trigger orders".to_string(),
1736 })
1737 }
1738}
1739
1740#[derive(Deserialize)]
1741struct BatchOrdersResult {
1742 #[serde(rename = "successList", default)]
1743 success_list: Vec<BatchSuccess>,
1744 #[serde(rename = "failureList", default)]
1745 failure_list: Vec<BatchFailure>,
1746}
1747
1748#[derive(Deserialize)]
1749struct BatchSuccess {
1750 #[serde(rename = "orderId", default)]
1751 order_id: String,
1752 #[serde(rename = "clientOid", default)]
1753 client_oid: String,
1754}
1755
1756#[derive(Deserialize)]
1757struct BatchFailure {
1758 #[serde(rename = "clientOid", default)]
1759 client_oid: String,
1760 #[serde(rename = "errorMsg", default)]
1761 error_msg: String,
1762}
1763
1764impl Exchange for Bitget {
1765 fn name(&self) -> &'static str {
1766 "bitget"
1767 }
1768}
1769
1770impl WsUserData for Bitget {
1771 fn subscribe_user_data(&mut self) -> Result<()> {
1772 Bitget::subscribe_user_data(self)
1773 }
1774 fn keepalive_user_data(&mut self) -> Result<()> {
1775 Bitget::keepalive_user_data(self)
1776 }
1777}
1778
1779impl WsExecution for Bitget {
1780 fn place_order_ws(&mut self, _request: &OrderRequest) -> Result<Order> {
1785 Err(Error::Exchange {
1786 code: "unsupported".to_string(),
1787 message: "Bitget has no WebSocket order-entry API; place orders over \
1788 REST (POST /api/v2/spot/trade/place-order)"
1789 .to_string(),
1790 })
1791 }
1792
1793 fn cancel_order_ws(&mut self, _symbol: &Symbol, _order_id: &str) -> Result<()> {
1794 Err(Error::Exchange {
1795 code: "unsupported".to_string(),
1796 message: "Bitget has no WebSocket order-entry API; cancel orders over \
1797 REST (POST /api/v2/spot/trade/cancel-order)"
1798 .to_string(),
1799 })
1800 }
1801}
1802
1803impl Derivatives for Bitget {
1804 fn positions(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Position>> {
1805 Bitget::positions(self, symbol)
1806 }
1807 fn set_leverage(&mut self, symbol: &Symbol, leverage: u32) -> Result<()> {
1808 Bitget::set_leverage(self, symbol, leverage)
1809 }
1810 fn set_margin_mode(&mut self, symbol: &Symbol, mode: MarginMode) -> Result<()> {
1811 Bitget::set_margin_mode(self, symbol, mode)
1812 }
1813 fn close_position(&mut self, symbol: &Symbol) -> Result<Order> {
1814 Bitget::close_position(self, symbol)
1815 }
1816}
1817
1818impl DerivativesStream for Bitget {
1819 fn subscribe_derivatives(
1820 &mut self,
1821 symbol: &Symbol,
1822 channel: DerivativesChannel,
1823 ) -> Result<()> {
1824 Bitget::subscribe_derivatives(self, symbol, channel)
1825 }
1826 fn open_interest(&mut self, symbol: &Symbol) -> Result<OpenInterest> {
1827 Bitget::open_interest(self, symbol)
1828 }
1829 fn long_short_ratio(&mut self, symbol: &Symbol) -> Result<LongShortRatio> {
1830 Bitget::long_short_ratio(self, symbol)
1831 }
1832}
1833
1834#[derive(Deserialize)]
1835struct RawMixAccount {
1836 #[serde(rename = "marginCoin")]
1837 margin_coin: String,
1838 #[serde(default)]
1839 available: String,
1840 #[serde(default)]
1841 locked: String,
1842}
1843
1844#[derive(Deserialize)]
1845struct RawBitgetPosition {
1846 symbol: String,
1847 #[serde(rename = "holdSide")]
1848 hold_side: String,
1849 total: String,
1850 #[serde(rename = "openPriceAvg", default)]
1851 open_price_avg: String,
1852 #[serde(rename = "markPrice", default)]
1853 mark_price: String,
1854 leverage: String,
1855 #[serde(rename = "unrealizedPL", default)]
1856 unrealized_pl: String,
1857 #[serde(rename = "marginMode", default)]
1858 margin_mode: String,
1859}
1860
1861fn parse_bitget_position(raw: &RawBitgetPosition) -> Option<Result<Position>> {
1862 let total = match parse_decimal(&raw.total) {
1863 Ok(total) if !total.is_zero() => total,
1864 Ok(_) => return None, Err(e) => return Some(Err(e)),
1866 };
1867 let side = if raw.hold_side == "short" {
1868 PositionSide::Short
1869 } else {
1870 PositionSide::Long
1871 };
1872 let build = || -> Result<Position> {
1873 Ok(Position {
1874 symbol: split_wire_symbol(&raw.symbol),
1875 side,
1876 quantity: total,
1877 entry_price: parse_decimal(&raw.open_price_avg)?,
1878 mark_price: parse_decimal(&raw.mark_price)?,
1879 leverage: parse_decimal(&raw.leverage)?,
1880 unrealized_pnl: parse_decimal(&raw.unrealized_pl)?,
1881 margin_mode: if raw.margin_mode.eq_ignore_ascii_case("isolated") {
1882 MarginMode::Isolated
1883 } else {
1884 MarginMode::Cross
1885 },
1886 })
1887 };
1888 Some(build())
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894 use crate::transport::{HttpResponse, MockHttpTransport, MockWsTransport};
1895 use rust_decimal_macros::dec;
1896 use std::sync::Arc;
1897
1898 struct ArcTransport(Arc<MockHttpTransport>);
1899 impl HttpTransport for ArcTransport {
1900 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
1901 self.0.execute(request)
1902 }
1903 }
1904 struct ArcWs(Arc<MockWsTransport>);
1905 impl WsTransport for ArcWs {
1906 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
1907 self.0.connect(url)
1908 }
1909 }
1910
1911 fn symbol() -> Symbol {
1912 Symbol::new("BTC", "USDT")
1913 }
1914
1915 #[test]
1916 fn a_blocked_reply_is_reported_as_the_block_not_as_drift() {
1917 let (bitget, mock) = client();
1918 mock.push_json(403, "{\n error: 'unavailable in your region'\n}");
1922 let error = bitget.ticker(&symbol()).unwrap_err();
1923 let named = matches!(&error, Error::Exchange { code, .. } if code == "403");
1924 assert!(named, "a blocked reply must not be reported as drift");
1925 }
1926
1927 fn client() -> (Bitget, Arc<MockHttpTransport>) {
1928 let mock = Arc::new(MockHttpTransport::new());
1929 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1930 (
1931 Bitget::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
1932 mock,
1933 )
1934 }
1935
1936 fn signed_client(now_ms: i64) -> (Bitget, Arc<MockHttpTransport>) {
1937 let mock = Arc::new(MockHttpTransport::new());
1938 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1939 let bitget = Bitget::with_credentials(
1940 Box::new(ArcTransport(Arc::clone(&mock))),
1941 &opts,
1942 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
1943 )
1944 .with_clock(Box::new(move || now_ms));
1945 (bitget, mock)
1946 }
1947
1948 fn signed_ws_client(now_ms: i64) -> (Bitget, Arc<MockWsTransport>) {
1949 let http = Arc::new(MockHttpTransport::new());
1950 let ws = Arc::new(MockWsTransport::new());
1951 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1952 let bitget = Bitget::with_credentials(
1953 Box::new(ArcTransport(http)),
1954 &opts,
1955 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
1956 )
1957 .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
1958 .with_clock(Box::new(move || now_ms));
1959 (bitget, ws)
1960 }
1961
1962 fn futures_ws_client(now_ms: i64) -> (Bitget, Arc<MockWsTransport>) {
1964 let http = Arc::new(MockHttpTransport::new());
1965 let ws = Arc::new(MockWsTransport::new());
1966 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
1967 let bitget = Bitget::with_credentials(
1968 Box::new(ArcTransport(http)),
1969 &opts,
1970 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
1971 )
1972 .with_ws(Box::new(ArcWs(Arc::clone(&ws))))
1973 .with_clock(Box::new(move || now_ms));
1974 (bitget, ws)
1975 }
1976
1977 fn futures_client() -> (Bitget, Arc<MockHttpTransport>) {
1979 let mock = Arc::new(MockHttpTransport::new());
1980 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
1981 (
1982 Bitget::with_http(Box::new(ArcTransport(Arc::clone(&mock))), &opts),
1983 mock,
1984 )
1985 }
1986 #[test]
1987 fn a_spot_client_refuses_the_derivatives_surface() {
1988 let (mut bitget, _) = client();
1989 for channel in [
1990 DerivativesChannel::Funding,
1991 DerivativesChannel::MarkIndex,
1992 DerivativesChannel::Liquidations,
1993 ] {
1994 let err = Bitget::subscribe_derivatives(&mut bitget, &symbol(), channel)
1995 .expect_err("spot has no derivatives channels");
1996 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
1997 }
1998 let (bitget, _) = client();
1999 assert!(Bitget::open_interest(&bitget, &symbol()).is_err());
2000 assert!(Bitget::long_short_ratio(&bitget, &symbol()).is_err());
2001 }
2002
2003 #[test]
2006 fn liquidations_are_refused_because_bitget_publishes_none() {
2007 let (mut bitget, ws) = futures_ws_client(1_700_000_000_000);
2008 let err =
2009 Bitget::subscribe_derivatives(&mut bitget, &symbol(), DerivativesChannel::Liquidations)
2010 .expect_err("Bitget has no public liquidation stream");
2011 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
2012 assert!(ws.sent().is_empty(), "refused, yet subscribed to something");
2013 }
2014
2015 #[test]
2018 fn the_ticker_frame_answers_funding_and_mark_index_together() {
2019 let (mut bitget, ws) = futures_ws_client(1_700_000_000_000);
2020 ws.push_connection(vec![Ok(Some(
2021 r#"{"action":"snapshot","arg":{"instType":"USDT-FUTURES","channel":"ticker",
2022 "instId":"BTCUSDT"},"data":[{"instId":"BTCUSDT","lastPr":"81115.8","bidPr":"81115.7",
2023 "askPr":"81115.9","baseVolume":"12345","markPrice":"81117","indexPrice":"81155.1985",
2024 "fundingRate":"0.000039","ts":"1788459657009"}]}"#
2025 .to_string(),
2026 ))]);
2027 Bitget::subscribe_derivatives(&mut bitget, &symbol(), DerivativesChannel::Funding).unwrap();
2028 Bitget::subscribe_derivatives(&mut bitget, &symbol(), DerivativesChannel::MarkIndex)
2029 .unwrap();
2030
2031 let events = Bitget::poll_events(&mut bitget);
2032 assert_eq!(
2033 events.len(),
2034 3,
2035 "ticker, funding and mark/index: {events:?}"
2036 );
2037
2038 let Event::Derivatives(DerivativesFeed::Funding(funding)) = &events[1] else {
2039 panic!("expected a funding print, got {:?}", events[1]);
2040 };
2041 assert_eq!(funding.rate, dec!(0.000039));
2042 assert_eq!(funding.mark_price, dec!(81117));
2043 assert_eq!(funding.timestamp, 1_788_459_657_009);
2044
2045 let Event::Derivatives(DerivativesFeed::MarkIndex(mark)) = &events[2] else {
2046 panic!("expected a mark/index print, got {:?}", events[2]);
2047 };
2048 assert_eq!(mark.mark_price, dec!(81117));
2049 assert_eq!(mark.index_price, dec!(81155.1985));
2050 }
2051
2052 #[test]
2054 fn an_unsubscribed_derivatives_print_is_not_emitted() {
2055 let (mut bitget, ws) = futures_ws_client(1_700_000_000_000);
2056 ws.push_connection(vec![Ok(Some(
2057 r#"{"action":"snapshot","arg":{"instType":"USDT-FUTURES","channel":"ticker",
2058 "instId":"BTCUSDT"},"data":[{"instId":"BTCUSDT","lastPr":"81115.8","bidPr":"81115.7",
2059 "askPr":"81115.9","baseVolume":"12345","markPrice":"81117","indexPrice":"81155.1985",
2060 "fundingRate":"0.000039","ts":"1788459657009"}]}"#
2061 .to_string(),
2062 ))]);
2063 Bitget::subscribe_ticker(&mut bitget, &symbol()).unwrap();
2064
2065 let events = Bitget::poll_events(&mut bitget);
2066 assert_eq!(events.len(), 1, "only the ticker was asked for: {events:?}");
2067 assert!(matches!(events[0], Event::Ticker(_)));
2068 }
2069
2070 #[test]
2071 fn open_interest_reads_the_base_currency_figure() {
2072 let (bitget, mock) = futures_client();
2073 mock.push_json(
2074 200,
2075 r#"{"code":"00000","msg":"success","requestTime":1788459644402,
2076 "data":{"openInterestList":[{"symbol":"BTCUSDT","size":"34083.9067999999127"}],
2077 "ts":"1788459644403"}}"#,
2078 );
2079 let open_interest = Bitget::open_interest(&bitget, &symbol()).unwrap();
2080 assert_eq!(open_interest.open_interest, dec!(34083.9067999999127));
2081 assert_eq!(open_interest.timestamp, 1_788_459_644_403);
2082 assert!(mock.recorded_requests()[0]
2083 .url
2084 .contains("productType=USDT-FUTURES"));
2085 }
2086
2087 #[test]
2089 fn the_long_short_ratio_is_read_not_derived() {
2090 let (bitget, mock) = futures_client();
2091 mock.push_json(
2092 200,
2093 r#"{"code":"00000","msg":"success","requestTime":1788459645484,
2094 "data":[{"longAccountRatio":"0.5236","shortAccountRatio":"0.4764",
2095 "longShortAccountRatio":"1.099","ts":"1788450600000"},
2096 {"longAccountRatio":"0.5255","shortAccountRatio":"0.4745",
2097 "longShortAccountRatio":"1.107","ts":"1788450000000"}]}"#,
2098 );
2099 let ratio = Bitget::long_short_ratio(&bitget, &symbol()).unwrap();
2100 assert_eq!(ratio.long_size, dec!(0.5236));
2101 assert_eq!(ratio.short_size, dec!(0.4764));
2102 assert_eq!(ratio.timestamp, 1_788_450_600_000);
2104 }
2105
2106 #[test]
2116 fn a_futures_client_subscribes_to_futures_streams_not_spot() {
2117 let (mut bitget, ws) = futures_ws_client(1_700_000_000_000);
2118 Bitget::subscribe_trades(&mut bitget, &symbol()).unwrap();
2119 Bitget::subscribe_book(&mut bitget, &symbol()).unwrap();
2120
2121 for frame in ws.sent() {
2122 assert!(
2123 frame.contains(r#""instType":"USDT-FUTURES""#),
2124 "a futures client subscribed to something else: {frame}"
2125 );
2126 assert!(!frame.contains(r#""instType":"SPOT""#));
2127 }
2128
2129 let (mut bitget, ws) = futures_ws_client(1_700_000_000_000);
2132 ws.push_connection(vec![Ok(Some(
2133 r#"{"event":"login","code":"0"}"#.to_string(),
2134 ))]);
2135 Bitget::subscribe_user_data(&mut bitget).unwrap();
2136 let subscribe = ws
2137 .sent()
2138 .into_iter()
2139 .find(|frame| frame.contains("\"channel\":\"orders\""))
2140 .expect("the user-data stream subscribes to orders");
2141 assert!(
2142 subscribe.contains(r#""instType":"USDT-FUTURES""#),
2143 "{subscribe}"
2144 );
2145 }
2146
2147 #[test]
2149 fn a_spot_client_still_subscribes_to_spot_streams() {
2150 let (mut bitget, ws) = signed_ws_client(1_700_000_000_000);
2151 Bitget::subscribe_trades(&mut bitget, &symbol()).unwrap();
2152 assert!(ws.sent()[0].contains(r#""instType":"SPOT""#));
2153 }
2154
2155 #[test]
2156 fn subscribe_user_data_logs_in_and_streams_orders_and_account() {
2157 let (mut bitget, ws) = signed_ws_client(1_700_000_000_000);
2158 ws.push_connection(vec![
2159 Ok(Some(r#"{"event":"login","code":"0"}"#.to_string())),
2160 Ok(Some(r#"{"event":"subscribe"}"#.to_string())),
2161 Ok(Some(
2162 r#"{"arg":{"instType":"SPOT","channel":"orders","instId":"default"},"data":[
2163 {"instId":"BTCUSDT","orderId":"55","clientOid":"my","side":"buy","orderType":"limit",
2164 "status":"filled","size":"1","baseVolume":"1","price":"100","priceAvg":"100"}]}"#
2165 .to_string(),
2166 )),
2167 Ok(Some(
2168 r#"{"arg":{"instType":"SPOT","channel":"account","coin":"default"},"data":[
2169 {"coin":"USDT","available":"900","frozen":"50"}]}"#
2170 .to_string(),
2171 )),
2172 ]);
2173 bitget.subscribe_user_data().unwrap();
2174 assert_eq!(ws.connected_urls()[0], "wss://ws.bitget.com/v2/ws/private");
2175 assert!(ws.sent()[0].contains(r#""op":"login""#));
2176 assert!(ws.sent()[0].contains(r#""apiKey":"APIKEY""#));
2177 assert!(ws.sent()[1].contains(r#""channel":"orders""#));
2178 assert!(ws.sent()[1].contains(r#""channel":"account""#));
2179
2180 let events = bitget.poll_events();
2181 assert_eq!(events.len(), 2);
2182 let Event::OrderUpdate(order) = &events[0] else {
2183 panic!("first event must be an order update");
2184 };
2185 assert_eq!(order.id, "55");
2186 assert_eq!(order.symbol, symbol());
2187 assert_eq!(order.side, OrderSide::Buy);
2188 assert_eq!(order.status, OrderStatus::Filled);
2189 assert_eq!(order.average_price, Some(dec!(100)));
2190 let Event::BalanceUpdate(balances) = &events[1] else {
2191 panic!("second event must be a balance update");
2192 };
2193 assert_eq!(balances.len(), 1);
2194 assert_eq!(balances[0].asset, "USDT");
2195 assert_eq!(balances[0].free, dec!(900));
2196 assert_eq!(balances[0].locked, dec!(50));
2197 }
2198
2199 #[test]
2200 fn subscribe_user_data_requires_a_passphrase() {
2201 let http = Arc::new(MockHttpTransport::new());
2202 let ws = Arc::new(MockWsTransport::new());
2203 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2204 let mut bitget = Bitget::with_credentials(
2205 Box::new(ArcTransport(http)),
2206 &opts,
2207 Credentials::new("APIKEY", "SECRET"),
2208 )
2209 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2210 assert!(matches!(
2211 bitget.subscribe_user_data().unwrap_err(),
2212 Error::InvalidCredentials(_)
2213 ));
2214 }
2215
2216 #[test]
2217 fn keepalive_user_data_pings_the_private_stream() {
2218 let (mut bitget, ws) = signed_ws_client(1_700_000_000_000);
2219 ws.push_connection(vec![]);
2220 bitget.subscribe_user_data().unwrap();
2221 bitget.keepalive_user_data().unwrap();
2222 assert!(ws.sent().iter().any(|f| f == "ping"));
2223 }
2224
2225 #[test]
2226 fn keepalive_user_data_is_a_noop_before_subscribe() {
2227 let (mut bitget, ws) = signed_ws_client(1_700_000_000_000);
2228 bitget.keepalive_user_data().unwrap();
2229 assert_eq!(ws.sent(), Vec::<String>::new());
2230 }
2231
2232 #[test]
2233 fn dropped_user_data_stream_reconnects_with_a_fresh_login() {
2234 let (mut bitget, ws) = signed_ws_client(1_700_000_000_000);
2235 ws.push_connection(vec![Ok(None)]);
2238 ws.push_connection(vec![]);
2239 bitget.subscribe_user_data().unwrap();
2240
2241 let events = bitget.poll_events();
2242 assert!(events.contains(&Event::Disconnected));
2243 assert!(events.contains(&Event::Reconnected));
2244 let login_frames = ws
2246 .sent()
2247 .into_iter()
2248 .filter(|f| f.contains(r#""op":"login""#))
2249 .count();
2250 assert_eq!(login_frames, 2);
2251 assert_eq!(ws.connected_urls().len(), 2);
2252 assert_eq!(ws.connected_urls()[1], "wss://ws.bitget.com/v2/ws/private");
2253 }
2254
2255 #[test]
2256 fn ws_execution_is_a_documented_gap() {
2257 let (mut bitget, _mock) = client();
2260 assert!(matches!(
2261 bitget
2262 .place_order_ws(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2263 .unwrap_err(),
2264 Error::Exchange { .. }
2265 ));
2266 assert!(matches!(
2267 bitget.cancel_order_ws(&symbol(), "1").unwrap_err(),
2268 Error::Exchange { .. }
2269 ));
2270 }
2271
2272 fn signed_futures_client(now_ms: i64) -> (Bitget, Arc<MockHttpTransport>) {
2273 let mock = Arc::new(MockHttpTransport::new());
2274 let opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2275 let bitget = Bitget::with_credentials(
2276 Box::new(ArcTransport(Arc::clone(&mock))),
2277 &opts,
2278 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2279 )
2280 .with_clock(Box::new(move || now_ms));
2281 (bitget, mock)
2282 }
2283
2284 #[test]
2291 fn a_spot_trigger_goes_to_the_plan_endpoint_and_watches_the_traded_price() {
2292 let (bitget, mock) = signed_client(1_700_000_000_000);
2293 mock.push_json(
2294 200,
2295 r#"{"code":"00000","msg":"success","data":{"orderId":"p-1","clientOid":""}}"#,
2296 );
2297 let request = OrderRequest {
2298 order_type: OrderType::StopMarket,
2299 stop_price: Some(dec!(19000)),
2300 ..OrderRequest::market_sell(symbol(), dec!(1))
2301 };
2302 Bitget::place_order(&bitget, &request).unwrap();
2303
2304 let sent = &mock.recorded_requests()[0];
2305 assert!(
2306 sent.url.contains("/api/v2/spot/trade/place-plan-order"),
2307 "{}",
2308 sent.url
2309 );
2310 let body = sent.body.clone().unwrap();
2311 assert!(body.contains(r#""triggerPrice":"19000""#), "{body}");
2312 assert!(body.contains(r#""triggerType":"fill_price""#), "{body}");
2313 }
2314
2315 #[test]
2316 fn a_futures_trigger_watches_the_mark_and_names_its_plan_type() {
2317 let (bitget, mock) = signed_futures_client(1_700_000_000_000);
2318 mock.push_json(
2319 200,
2320 r#"{"code":"00000","msg":"success","data":{"orderId":"p-2","clientOid":""}}"#,
2321 );
2322 let request = OrderRequest {
2323 order_type: OrderType::StopLimit,
2324 stop_price: Some(dec!(19000)),
2325 ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
2326 };
2327 Bitget::place_order(&bitget, &request).unwrap();
2328
2329 let sent = &mock.recorded_requests()[0];
2330 assert!(
2331 sent.url.contains("/api/v2/mix/order/place-plan-order"),
2332 "{}",
2333 sent.url
2334 );
2335 let body = sent.body.clone().unwrap();
2336 assert!(body.contains(r#""triggerType":"mark_price""#), "{body}");
2337 assert!(body.contains(r#""planType":"normal_plan""#), "{body}");
2340 assert!(body.contains(r#""executePrice":"18900""#), "{body}");
2341 assert!(body.contains(r#""productType":"USDT-FUTURES""#), "{body}");
2342 }
2343
2344 #[test]
2345 fn a_bitget_trigger_without_a_stop_price_is_refused() {
2346 let (bitget, mock) = signed_client(1_700_000_000_000);
2347 let request = OrderRequest {
2348 order_type: OrderType::StopMarket,
2349 stop_price: None,
2350 ..OrderRequest::market_sell(symbol(), dec!(1))
2351 };
2352 assert!(Bitget::place_order(&bitget, &request).is_err());
2353 assert_eq!(mock.recorded_requests(), Vec::new());
2354 }
2355
2356 const MIX_POSITIONS: &str = r#"{"code":"00000","msg":"success","data":[
2357 {"symbol":"BTCUSDT","holdSide":"long","total":"0.5","openPriceAvg":"20000","markPrice":"20100","leverage":"10","unrealizedPL":"50","marginMode":"isolated"}
2358 ]}"#;
2359
2360 #[test]
2361 fn stp_maps_to_stp_mode() {
2362 let (bitget, mock) = signed_client(1000);
2363 mock.push_json(
2364 200,
2365 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2366 );
2367 bitget
2368 .place_order(
2369 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2370 .with_stp(SelfTradePrevention::ExpireBoth),
2371 )
2372 .unwrap();
2373 let reqs = mock.recorded_requests();
2374 assert!(reqs[0]
2375 .body
2376 .as_ref()
2377 .unwrap()
2378 .contains(r#""stpMode":"cancel_both""#));
2379 }
2380
2381 fn isolated_futures_client(now_ms: i64) -> (Bitget, Arc<MockHttpTransport>) {
2382 let mock = Arc::new(MockHttpTransport::new());
2383 let mut opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2384 opts.margin_mode = MarginMode::Isolated;
2385 let bitget = Bitget::with_credentials(
2386 Box::new(ArcTransport(Arc::clone(&mock))),
2387 &opts,
2388 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2389 )
2390 .with_clock(Box::new(move || now_ms));
2391 (bitget, mock)
2392 }
2393
2394 fn hedged_futures_client(now_ms: i64) -> (Bitget, Arc<MockHttpTransport>) {
2395 let mock = Arc::new(MockHttpTransport::new());
2396 let mut opts = ExchangeOptions::mainnet(crate::MarketType::UsdMFutures);
2397 opts.position_mode = PositionMode::Hedge;
2398 let bitget = Bitget::with_credentials(
2399 Box::new(ArcTransport(Arc::clone(&mock))),
2400 &opts,
2401 Credentials::new("APIKEY", "SECRET").with_passphrase("PASS"),
2402 )
2403 .with_clock(Box::new(move || now_ms));
2404 (bitget, mock)
2405 }
2406
2407 #[test]
2408 fn post_only_maps_to_the_post_only_order_type() {
2409 let (bitget, mock) = signed_client(1000);
2410 mock.push_json(
2411 200,
2412 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2413 );
2414 bitget
2415 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).post_only())
2416 .unwrap();
2417 assert!(mock.recorded_requests()[0]
2418 .body
2419 .as_deref()
2420 .unwrap()
2421 .contains("post_only"));
2422 }
2423
2424 #[test]
2425 fn hedge_mode_sends_trade_side_instead_of_reduce_only() {
2426 let (hedged, mock) = hedged_futures_client(1000);
2427 for _ in 0..2 {
2428 mock.push_json(
2429 200,
2430 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2431 );
2432 }
2433 hedged
2434 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2435 .unwrap();
2436 hedged
2437 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).reduce_only())
2438 .unwrap();
2439 let reqs = mock.recorded_requests();
2440 assert!(reqs[0]
2441 .body
2442 .as_deref()
2443 .unwrap()
2444 .contains(r#""tradeSide":"open""#));
2445 assert!(reqs[1]
2446 .body
2447 .as_deref()
2448 .unwrap()
2449 .contains(r#""tradeSide":"close""#));
2450 assert!(!reqs[1].body.as_deref().unwrap().contains("reduceOnly"));
2451 }
2452
2453 #[test]
2454 fn the_batch_entry_carries_the_position_half_it_was_dropping() {
2455 let (one_way, mock) = signed_futures_client(1000);
2458 mock.push_json(
2459 200,
2460 r#"{"code":"00000","data":{
2461 "successList":[{"orderId":"o1","clientOid":"b-0"}],"failureList":[]}}"#,
2462 );
2463 one_way
2464 .place_batch(&[OrderRequest::limit_sell(symbol(), dec!(1), dec!(100))
2465 .reduce_only()
2466 .with_client_order_id("b-0")])
2467 .unwrap();
2468 assert!(mock.recorded_requests()[0]
2469 .body
2470 .as_deref()
2471 .unwrap()
2472 .contains(r#""reduceOnly":"YES""#));
2473
2474 let (hedged, hedged_mock) = hedged_futures_client(1000);
2475 hedged_mock.push_json(
2476 200,
2477 r#"{"code":"00000","data":{
2478 "successList":[{"orderId":"o1","clientOid":"b-0"}],"failureList":[]}}"#,
2479 );
2480 hedged
2481 .place_batch(&[OrderRequest::limit_sell(symbol(), dec!(1), dec!(100))
2482 .reduce_only()
2483 .with_client_order_id("b-0")])
2484 .unwrap();
2485 assert!(hedged_mock.recorded_requests()[0]
2486 .body
2487 .as_deref()
2488 .unwrap()
2489 .contains(r#""tradeSide":"close""#));
2490 }
2491
2492 #[test]
2493 fn configured_margin_mode_reaches_every_futures_order() {
2494 let (isolated, mock) = isolated_futures_client(1000);
2498 mock.push_json(
2499 200,
2500 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2501 );
2502 isolated
2503 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2504 .unwrap();
2505 mock.push_json(
2506 200,
2507 r#"{"code":"00000","data":{
2508 "successList":[{"orderId":"o1","clientOid":"b-0"}],"failureList":[]}}"#,
2509 );
2510 isolated
2511 .place_batch(&[
2512 OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("b-0")
2513 ])
2514 .unwrap();
2515
2516 let reqs = mock.recorded_requests();
2517 assert!(reqs[0]
2518 .body
2519 .as_deref()
2520 .unwrap()
2521 .contains(r#""marginMode":"isolated""#));
2522 assert!(reqs[1]
2523 .body
2524 .as_deref()
2525 .unwrap()
2526 .contains(r#""marginMode":"isolated""#));
2527
2528 let (cross, cross_mock) = signed_futures_client(1000);
2530 cross_mock.push_json(
2531 200,
2532 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2533 );
2534 cross
2535 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2536 .unwrap();
2537 let cross_reqs = cross_mock.recorded_requests();
2538 assert!(cross_reqs[0]
2539 .body
2540 .as_deref()
2541 .unwrap()
2542 .contains(r#""marginMode":"crossed""#));
2543 }
2544
2545 #[test]
2546 fn set_margin_mode_changes_what_later_orders_carry() {
2547 let (mut bitget, mock) = signed_futures_client(1000);
2550 mock.push_json(200, r#"{"code":"00000","data":{}}"#);
2551 mock.push_json(
2552 200,
2553 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2554 );
2555
2556 Derivatives::set_margin_mode(&mut bitget, &symbol(), MarginMode::Isolated).unwrap();
2557 bitget
2558 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2559 .unwrap();
2560
2561 let reqs = mock.recorded_requests();
2562 assert!(reqs[1]
2563 .body
2564 .as_deref()
2565 .unwrap()
2566 .contains(r#""marginMode":"isolated""#));
2567 }
2568
2569 #[test]
2572 fn the_public_reads_carry_the_venue_stamp() {
2573 let (bitget, mock) = signed_client(1000);
2574 mock.push_json(
2575 200,
2576 r#"{"code":"00000","msg":"success","data":[{"symbol":"BTCUSDT","lastPr":"77100.52","bidPr":"77100.51","askPr":"77100.52","baseVolume":"2886.70","ts":"1788396650211"}]}"#,
2577 );
2578 let ticker = bitget.ticker(&symbol()).unwrap();
2579 assert_eq!(ticker.timestamp, 1_788_396_650_211);
2580
2581 let (bitget, mock) = signed_client(1000);
2582 mock.push_json(
2583 200,
2584 r#"{"code":"00000","msg":"success","data":{"asks":[["77100.52","0.56"]],"bids":[["77100.51","0.37"]],"ts":"1788396651599"}}"#,
2585 );
2586 let book = bitget.order_book(&symbol(), 5).unwrap();
2587 assert_eq!(book.timestamp, 1_788_396_651_599);
2588 }
2589
2590 #[test]
2591 fn place_batch_aligns_success_and_failure() {
2592 let (bitget, mock) = signed_client(1000);
2593 mock.push_json(
2594 200,
2595 r#"{"code":"00000","data":{
2596 "successList":[{"orderId":"o1","clientOid":"wbatch-0"}],
2597 "failureList":[{"clientOid":"wbatch-1","errorMsg":"insufficient"}]}}"#,
2598 );
2599 let results = bitget
2600 .place_batch(&[
2601 OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2605 .with_client_order_id("wbatch-0"),
2606 OrderRequest::limit_buy(symbol(), dec!(1), dec!(101))
2607 .with_client_order_id("wbatch-1"),
2608 ])
2609 .unwrap();
2610 assert_eq!(results[0].as_ref().unwrap().id, "o1");
2611 assert!(matches!(
2612 results[1].as_ref().unwrap_err(),
2613 Error::OrderRejected { .. }
2614 ));
2615 let reqs = mock.recorded_requests();
2616 assert!(reqs[0].url.contains("/api/v2/spot/trade/batch-orders"));
2617 assert!(reqs[0]
2618 .body
2619 .as_ref()
2620 .unwrap()
2621 .contains(r#""clientOid":"wbatch-0""#));
2622 }
2623
2624 #[test]
2625 fn cancel_batch_is_one_call() {
2626 let (bitget, mock) = signed_client(1000);
2627 mock.push_json(200, r#"{"code":"00000","data":{"successList":[]}}"#);
2628 bitget
2629 .cancel_batch(&symbol(), &["1".to_string(), "2".to_string()])
2630 .unwrap();
2631 let reqs = mock.recorded_requests();
2632 assert_eq!(reqs.len(), 1);
2633 assert!(reqs[0]
2634 .url
2635 .contains("/api/v2/spot/trade/batch-cancel-order"));
2636 }
2637
2638 #[test]
2639 fn amend_and_oco_are_unsupported() {
2640 let (mut bitget, _mock) = signed_client(1000);
2641 assert!(matches!(
2642 AdvancedOrders::amend_order(&mut bitget, &symbol(), "1", Some(dec!(1)), None)
2643 .unwrap_err(),
2644 Error::Exchange { .. }
2645 ));
2646 assert!(matches!(
2647 AdvancedOrders::place_oco(
2648 &mut bitget,
2649 &OcoRequest::new(symbol(), OrderSide::Sell, dec!(1), dec!(110), dec!(95))
2650 )
2651 .unwrap_err(),
2652 Error::Exchange { .. }
2653 ));
2654 }
2655
2656 #[test]
2657 fn futures_query_order_uses_mix_detail_and_state() {
2658 let (bitget, mock) = signed_futures_client(1000);
2659 mock.push_json(
2660 200,
2661 r#"{"code":"00000","data":{"symbol":"BTCUSDT","orderId":"88","clientOid":"",
2662 "side":"buy","orderType":"limit","state":"filled","size":"2","baseVolume":"2",
2663 "price":"100","priceAvg":"100"}}"#,
2664 );
2665 let order = bitget.query_order(&symbol(), "88").unwrap();
2666 assert_eq!(order.id, "88");
2667 assert_eq!(order.status, OrderStatus::Filled);
2668 let url = &mock.recorded_requests()[0].url;
2669 assert!(url.contains("/api/v2/mix/order/detail"));
2670 assert!(url.contains("productType=USDT-FUTURES"));
2671 }
2672
2673 #[test]
2674 fn futures_cancel_and_open_orders_use_mix_endpoints() {
2675 let (bitget, mock) = signed_futures_client(1000);
2676 mock.push_json(200, r#"{"code":"00000","data":{"orderId":"88"}}"#);
2677 bitget.cancel_order(&symbol(), "88").unwrap();
2678 mock.push_json(
2679 200,
2680 r#"{"code":"00000","data":{"entrustedList":[{"symbol":"BTCUSDT","orderId":"90",
2681 "clientOid":"","side":"sell","orderType":"limit","state":"live","size":"3",
2682 "baseVolume":"0","price":"21000","priceAvg":"0"}]}}"#,
2683 );
2684 let orders = bitget.open_orders(Some(&symbol())).unwrap();
2685 assert_eq!(orders.len(), 1);
2686 assert_eq!(orders[0].side, OrderSide::Sell);
2687 let reqs = mock.recorded_requests();
2688 assert!(reqs[0].url.contains("/api/v2/mix/order/cancel-order"));
2689 assert!(reqs[1].url.contains("/api/v2/mix/order/orders-pending"));
2690 }
2691
2692 #[test]
2693 fn futures_place_batch_uses_mix_endpoint() {
2694 let (bitget, mock) = signed_futures_client(1000);
2695 mock.push_json(
2696 200,
2697 r#"{"code":"00000","data":{
2698 "successList":[{"orderId":"o1","clientOid":"wbatch-0"}],
2699 "failureList":[]}}"#,
2700 );
2701 let results = bitget
2702 .place_batch(&[OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
2703 .with_client_order_id("wbatch-0")])
2704 .unwrap();
2705 assert_eq!(results[0].as_ref().unwrap().id, "o1");
2706 let req = &mock.recorded_requests()[0];
2707 assert!(req.url.contains("/api/v2/mix/order/batch-place-order"));
2708 assert!(req
2709 .body
2710 .as_ref()
2711 .unwrap()
2712 .contains(r#""productType":"USDT-FUTURES""#));
2713 }
2714
2715 #[test]
2716 fn futures_ticker_uses_mix_path() {
2717 let (bitget, mock) = signed_futures_client(1000);
2718 mock.push_json(
2719 200,
2720 r#"{"code":"00000","msg":"success","data":[{"symbol":"BTCUSDT","lastPr":"20000","bidPr":"19999","askPr":"20001","baseVolume":"1000"}]}"#,
2721 );
2722 let ticker = bitget.ticker(&symbol()).unwrap();
2723 assert_eq!(ticker.last, dec!(20000));
2724 let url = &mock.recorded_requests()[0].url;
2725 assert!(url.contains("/api/v2/mix/market/ticker"));
2726 assert!(url.contains("productType=USDT-FUTURES"));
2727 }
2728
2729 #[test]
2730 fn futures_place_order_uses_mix_path() {
2731 let (bitget, mock) = signed_futures_client(1000);
2732 mock.push_json(
2733 200,
2734 r#"{"code":"00000","msg":"success","data":{"orderId":"9","clientOid":""}}"#,
2735 );
2736 bitget
2737 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(20000)))
2738 .unwrap();
2739 let req = &mock.recorded_requests()[0];
2740 assert!(req.url.contains("/api/v2/mix/order/place-order"));
2741 assert!(req.body.as_deref().unwrap().contains("USDT-FUTURES"));
2742 }
2743
2744 #[test]
2745 fn derivatives_positions_parse() {
2746 let (mut bitget, mock) = signed_futures_client(1000);
2747 mock.push_json(200, MIX_POSITIONS);
2748 let positions = Derivatives::positions(&mut bitget, Some(&symbol())).unwrap();
2749 assert_eq!(positions.len(), 1);
2750 assert_eq!(positions[0].symbol, Symbol::new("BTC", "USDT"));
2751 assert_eq!(positions[0].side, PositionSide::Long);
2752 assert_eq!(positions[0].quantity, dec!(0.5));
2753 assert_eq!(positions[0].leverage, dec!(10));
2754 assert_eq!(positions[0].margin_mode, MarginMode::Isolated);
2755 assert!(mock.recorded_requests()[0]
2756 .url
2757 .contains("/api/v2/mix/position/all-position"));
2758 }
2759
2760 #[test]
2761 fn derivatives_set_leverage_hits_endpoint() {
2762 let (mut bitget, mock) = signed_futures_client(1000);
2763 mock.push_json(200, r#"{"code":"00000","msg":"success","data":{}}"#);
2764 Derivatives::set_leverage(&mut bitget, &symbol(), 20).unwrap();
2765 let req = &mock.recorded_requests()[0];
2766 assert!(req.url.contains("/api/v2/mix/account/set-leverage"));
2767 assert!(req.body.as_deref().unwrap().contains(r#""leverage":"20""#));
2768 }
2769
2770 #[test]
2771 fn derivatives_close_position_reduce_only() {
2772 let (mut bitget, mock) = signed_futures_client(1000);
2773 mock.push_json(200, MIX_POSITIONS);
2774 mock.push_json(
2775 200,
2776 r#"{"code":"00000","msg":"success","data":{"orderId":"9","clientOid":""}}"#,
2777 );
2778 Derivatives::close_position(&mut bitget, &symbol()).unwrap();
2779 let req = &mock.recorded_requests()[1];
2780 assert!(req.url.contains("/api/v2/mix/order/place-order"));
2781 let body = req.body.as_deref().unwrap();
2782 assert!(body.contains(r#""side":"sell""#));
2783 assert!(body.contains(r#""reduceOnly":"YES""#));
2784 }
2785
2786 #[test]
2787 fn ticker_and_error_mapping() {
2788 let (bitget, mock) = client();
2789 mock.push_json(
2790 200,
2791 r#"{"code":"00000","msg":"success","data":[{"symbol":"BTCUSDT","lastPr":"20000",
2792 "bidPr":"19999","askPr":"20001","baseVolume":"1234"}]}"#,
2793 );
2794 let ticker = bitget.ticker(&symbol()).unwrap();
2795 assert_eq!(ticker.last, dec!(20000));
2796 assert_eq!(ticker.bid, dec!(19999));
2797
2798 let (bitget, mock) = client();
2799 mock.push_json(200, r#"{"code":"43012","msg":"insufficient","data":null}"#);
2800 assert!(matches!(
2801 bitget.ticker(&symbol()).unwrap_err(),
2802 Error::InsufficientBalance
2803 ));
2804 }
2805
2806 #[test]
2807 fn klines_chronological() {
2808 let (bitget, mock) = client();
2809 mock.push_json(
2810 200,
2811 r#"{"code":"00000","data":[["1700000000000","100","110","95","105","1","0"],
2812 ["1700000060000","105","106","104","105.5","2","0"]]}"#,
2813 );
2814 let candles = bitget.klines(&symbol(), "1m", 2).unwrap();
2815 assert_eq!(candles[0].timestamp, 1_700_000_000_000);
2816 assert_eq!(candles[1].timestamp, 1_700_000_060_000);
2817 }
2818
2819 #[test]
2820 fn order_book_parses() {
2821 let (bitget, mock) = client();
2822 mock.push_json(
2823 200,
2824 r#"{"code":"00000","data":{"ts":"88","bids":[["100","1"]],"asks":[["101","2"]]}}"#,
2825 );
2826 let book = bitget.order_book(&symbol(), 5).unwrap();
2827 assert_eq!(book.last_update_id, 88);
2828 assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
2829 }
2830
2831 #[test]
2832 fn place_order_signs_with_access_headers() {
2833 let (bitget, mock) = signed_client(1000);
2834 mock.push_json(
2835 200,
2836 r#"{"code":"00000","data":{"orderId":"42","clientOid":"abc"}}"#,
2837 );
2838 let order = bitget
2839 .place_order(
2840 &OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)).with_client_order_id("abc"),
2841 )
2842 .unwrap();
2843 assert_eq!(order.id, "42");
2844 assert_eq!(order.client_order_id.as_deref(), Some("abc"));
2845
2846 let req = &mock.recorded_requests()[0];
2847 assert_eq!(req.method, HttpMethod::Post);
2848 let header = |name: &str| {
2849 req.headers
2850 .iter()
2851 .find(|(k, _)| k == name)
2852 .map(|(_, v)| v.as_str())
2853 .unwrap()
2854 };
2855 let ts = header("ACCESS-TIMESTAMP");
2856 assert_eq!(ts, "1000");
2857 let body = req.body.as_ref().unwrap();
2858 let prehash = format!("{ts}POST/api/v2/spot/trade/place-order{body}");
2859 let expected = hmac_sha256_base64(b"SECRET", prehash.as_bytes());
2860 assert_eq!(header("ACCESS-SIGN"), expected);
2861 assert_eq!(header("ACCESS-KEY"), "APIKEY");
2862 assert_eq!(header("ACCESS-PASSPHRASE"), "PASS");
2863 }
2864
2865 #[test]
2866 fn query_and_balances_and_open_orders() {
2867 let (bitget, mock) = signed_client(1000);
2868 mock.push_json(
2869 200,
2870 r#"{"code":"00000","data":[{"symbol":"BTCUSDT","orderId":"42","clientOid":"",
2871 "side":"sell","orderType":"market","status":"filled","size":"2","baseVolume":"2",
2872 "price":"0","priceAvg":"100"}]}"#,
2873 );
2874 let order = bitget.query_order(&symbol(), "42").unwrap();
2875 assert_eq!(order.status, OrderStatus::Filled);
2876 assert_eq!(order.average_price, Some(dec!(100)));
2877 assert_eq!(order.price, None);
2878
2879 mock.push_json(
2880 200,
2881 r#"{"code":"00000","data":[{"coin":"USDT","available":"100.5","frozen":"25.5"}]}"#,
2882 );
2883 let bals = bitget.balances().unwrap();
2884 assert_eq!(bals[0].total(), dec!(126));
2885
2886 mock.push_json(
2887 200,
2888 r#"{"code":"00000","data":[{"symbol":"ETHUSDT","orderId":"7","clientOid":"",
2889 "side":"buy","orderType":"limit","status":"live","size":"1","baseVolume":"0",
2890 "price":"50","priceAvg":"0"}]}"#,
2891 );
2892 let open = bitget.open_orders(None).unwrap();
2893 assert_eq!(open[0].symbol, Symbol::new("ETH", "USDT"));
2894 }
2895
2896 #[test]
2897 fn signed_requires_passphrase() {
2898 let mock = Arc::new(MockHttpTransport::new());
2899 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2900 let bitget = Bitget::with_credentials(
2901 Box::new(ArcTransport(mock)),
2902 &opts,
2903 Credentials::new("k", "s"),
2904 );
2905 assert!(matches!(
2906 bitget.balances().unwrap_err(),
2907 Error::InvalidCredentials(_)
2908 ));
2909 }
2910
2911 #[test]
2912 fn ws_parses_trade_and_book() {
2913 let ws = Arc::new(MockWsTransport::new());
2914 ws.push_connection(vec![
2915 Ok(Some(
2916 r#"{"action":"snapshot","arg":{"instType":"SPOT","channel":"trade","instId":"BTCUSDT"},
2917 "data":[{"ts":"1","price":"100","size":"0.5","side":"buy"}]}"#
2918 .to_string(),
2919 )),
2920 Ok(Some(
2921 r#"{"action":"snapshot","arg":{"instType":"SPOT","channel":"books","instId":"BTCUSDT"},
2922 "data":[{"ts":"5","bids":[["100","1"]],"asks":[["101","2"]]}]}"#
2923 .to_string(),
2924 )),
2925 ]);
2926 let http = Arc::new(MockHttpTransport::new());
2927 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
2928 let mut bitget = Bitget::with_http(Box::new(ArcTransport(http)), &opts)
2929 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
2930 bitget.subscribe_trades(&symbol()).unwrap();
2931 assert!(ws.sent()[0].contains(r#""channel":"trade""#));
2932
2933 let events = bitget.poll_events();
2934 assert_eq!(events.len(), 2);
2935 let Event::Trade(t) = &events[0] else {
2936 panic!("expected trade")
2937 };
2938 assert_eq!(t.aggressor, OrderSide::Buy);
2939 assert!(matches!(events[1], Event::BookSnapshot(_)));
2940 }
2941
2942 #[test]
2943 fn works_as_a_boxed_exchange() {
2944 let (bitget, mock) = signed_client(1000);
2945 mock.push_json(
2946 200,
2947 r#"{"code":"00000","data":{"orderId":"1","clientOid":""}}"#,
2948 );
2949 let mut exchange: Box<dyn Exchange> = Box::new(bitget);
2950 assert_eq!(exchange.name(), "bitget");
2951 let order = exchange
2952 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
2953 .unwrap();
2954 assert_eq!(order.id, "1");
2955 }
2956
2957 #[test]
2958 fn system_clock_is_sane() {
2959 assert!(system_now_ms() > 1_600_000_000_000);
2960 }
2961
2962 #[test]
2966 fn debug_reports_state_without_credentials() {
2967 let (client, _http) = signed_client(1_700_000_000_000);
2968 let rendered = format!("{client:?}");
2969
2970 assert!(rendered.starts_with("Bitget {"));
2971 assert!(rendered.contains("authenticated: true"));
2972 assert!(!rendered.contains("SECRET"));
2973 assert!(!rendered.contains("APIKEY"));
2974 }
2975
2976 #[test]
2983 fn sync_time_shifts_signed_timestamps() {
2984 let (mut bitget, http) = signed_client(1_000_000);
2985
2986 http.push_json(
2988 200,
2989 r#"{"code":"00000","msg":"success","data":{"serverTime":"1004500"}}"#,
2990 );
2991 let offset = bitget.sync_time().expect("sync must succeed");
2992 assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
2993
2994 http.push_json(200, r#"{"code":"00000","data":[]}"#);
2995 bitget.balances().expect("balances must succeed");
2996
2997 let requests = http.recorded_requests();
2998 let signed = requests.last().expect("a signed request was recorded");
2999 let header = signed
3000 .headers
3001 .iter()
3002 .find(|(name, _)| name == "ACCESS-TIMESTAMP")
3003 .map(|(_, value)| value.as_str())
3004 .expect("signed request carries ACCESS-TIMESTAMP");
3005 assert_eq!(header, "1004500", "the venue's time, not ours");
3006 }
3007
3008 #[test]
3016 fn batch_client_ids_do_not_repeat_across_batches() {
3017 let (bitget, mock) = signed_client(1000);
3018 let empty = r#"{"code":"00000","data":{"successList":[],"failureList":[]}}"#;
3019 mock.push_json(200, empty);
3020 mock.push_json(200, empty);
3021
3022 for _ in 0..2 {
3023 let _ = bitget.place_batch(&[OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))]);
3024 }
3025
3026 let bodies: Vec<String> = mock
3027 .recorded_requests()
3028 .iter()
3029 .filter_map(|request| request.body.clone())
3030 .collect();
3031 assert_eq!(bodies.len(), 2, "two batches were sent");
3032 assert_ne!(
3033 bodies[0], bodies[1],
3034 "the second batch must not reuse the first batch's client id"
3035 );
3036 }
3037
3038 #[test]
3046 fn rate_limit_carries_the_venues_advised_wait() {
3047 let (bitget, mock) = client();
3048 mock.push_response(
3049 crate::transport::HttpResponse::new(200, r#"{"code":"429","msg":"rate","data":null}"#)
3050 .with_header("Retry-After", "2.5"),
3051 );
3052 let err = bitget.ticker(&symbol()).unwrap_err();
3053 let wait = std::time::Duration::from_millis(2500);
3054 assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
3055 }
3056}