Skip to main content

wickra_exchange_core/exchanges/
bitget.rs

1//! Bitget (v2 API) — the fourth exchange.
2//!
3//! Bitget's signing is close to OKX's — base64(HMAC-SHA256) with a passphrase —
4//! but over a **millisecond** timestamp rather than ISO-8601, with `ACCESS-*`
5//! headers, concatenated symbols (`BTCUSDT`) and a `{code, msg, data}` envelope
6//! whose success code is the string `"00000"`.
7//!
8//! On a futures [`MarketType`](crate::MarketType), `query_order`/`cancel_order`/
9//! `open_orders` route to the mix order endpoints (`/api/v2/mix/order/detail`,
10//! `/cancel-order`, `/orders-pending`) with `productType=USDT-FUTURES` and the
11//! mix order object (`state` instead of `status`, `entrustedList` wrapper).
12//!
13//! [`AdvancedOrders`]: STP via `stpMode` on order create and native batch
14//! place/cancel — spot (`/api/v2/spot/trade/batch-orders`, `.../batch-cancel-order`)
15//! or mix (`/api/v2/mix/order/batch-place-order`, `.../batch-cancel-orders`),
16//! per-order results. Bitget has no in-place amend and no OCO order-list — both
17//! documented gaps.
18
19use 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
47/// The current Unix time in milliseconds, from the system clock.
48fn 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
56/// Bitget's `tradeSide` for a hedged futures order: `open` grows the side the
57/// order's own side implies, `close` reduces the opposite one. One-way orders
58/// carry `reduceOnly` instead and no `tradeSide` at all.
59fn trade_side(request: &OrderRequest) -> &'static str {
60    if request.reduce_only {
61        "close"
62    } else {
63        "open"
64    }
65}
66
67/// Bitget's wire word for a margin mode. Cross is `crossed`, not `cross`.
68fn margin_wire(mode: MarginMode) -> &'static str {
69    match mode {
70        MarginMode::Cross => "crossed",
71        MarginMode::Isolated => "isolated",
72    }
73}
74
75/// A Bitget client over injected transports.
76pub struct Bitget {
77    http: Box<dyn HttpTransport>,
78    ws: Option<Box<dyn WsTransport>>,
79    rest_base: String,
80    market_type: MarketType,
81    /// The margin mode every futures order carries, kept in step by
82    /// [`set_margin_mode`](Self::set_margin_mode).
83    margin_mode: MarginMode,
84    /// One-way or hedge. Bitget's v2 mix API says which side a hedged order
85    /// acts on with `tradeSide`, alongside the plain buy/sell `side`.
86    position_mode: PositionMode,
87    credentials: Option<Credentials>,
88    now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
89    /// Client order ids for orders the caller did not name.
90    ///
91    /// Seeded from the clock at construction and monotonic from there.
92    /// The previous fallback was the order's index within its batch, which
93    /// is unique inside one batch and repeats across every batch after it.
94    client_ids: ClientIdGenerator,
95    /// Offset between this machine's clock and the venue's, applied to every
96    /// signed timestamp. Zero until [`sync_time`](Self::sync_time) is called.
97    clock: ServerClock,
98    connection: Option<Box<dyn WsConnection>>,
99    sub_messages: Vec<String>,
100    subscriptions: Vec<(String, Symbol)>,
101    /// Which derivatives channels are subscribed, per wire symbol.
102    ///
103    /// Bitget publishes funding and mark/index inside the ordinary `ticker`
104    /// frame, so one frame can answer three subscriptions. Only the prints
105    /// that were asked for are emitted: a caller watching prices does not
106    /// start receiving funding it never subscribed to.
107    derivatives_channels: Vec<(String, DerivativesChannel)>,
108    /// The private user-data connection, opened by
109    /// [`subscribe_user_data`](Self::subscribe_user_data) and drained by
110    /// [`poll_events`](Self::poll_events) alongside the public stream.
111    private_connection: Option<Box<dyn WsConnection>>,
112    /// Set once the private stream is subscribed, so [`poll_events`](Self::poll_events)
113    /// re-subscribes it after a drop.
114    user_data_active: bool,
115}
116
117/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
118/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
119/// transports are shown by whether a connection is open rather than by value, and
120/// the credentials only by whether they are set -- printing them would put secret
121/// material into every log line that formats a client.
122impl 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    /// Refuse a market this client does not route.
141    ///
142    /// Called at every seam that reaches the venue -- the HTTP helpers and each
143    /// WebSocket connect -- so an unrouted market is refused before a request
144    /// is built rather than answered by whichever market the URL happened to
145    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
146    /// for what each client used to answer instead.
147    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    /// The timestamp a signed request must carry: this machine's clock plus the
180    /// offset learned from the venue.
181    ///
182    /// A venue rejects a signed request whose timestamp falls outside its own
183    /// receive window, so a machine a few seconds off has every order refused --
184    /// with a message about the window rather than about the clock. Until
185    /// [`sync_time`](Self::sync_time) is called the offset is zero and this is
186    /// the local time, which is the previous behaviour.
187    fn signed_now_ms(&self) -> i64 {
188        self.clock.server_time_ms((self.now_ms)())
189    }
190
191    /// Learn the offset between this machine's clock and the venue's, from
192    /// `GET /api/v2/public/time`.
193    ///
194    /// Explicit rather than automatic: it costs a request, and a client should
195    /// not make one the caller did not ask for. Call it once after connecting,
196    /// and again if the process runs long enough for drift to matter.
197    ///
198    /// Returns the new offset in milliseconds (`server - local`).
199    ///
200    /// # Errors
201    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
202    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    /// Whether this client targets a USDⓈ-M futures market (Bitget's `mix`
216    /// product line, `USDT-FUTURES` / margin coin `USDT`) rather than spot.
217    fn is_futures(&self) -> bool {
218        self.market_type.is_derivatives()
219    }
220
221    /// The `instType` every WebSocket subscription is keyed by.
222    ///
223    /// Bitget v2 serves one socket URL for every product and distinguishes them
224    /// here, exactly as the REST paths distinguish them with `productType`. Both
225    /// subscribe paths hardcoded `SPOT`, so a futures client streamed the spot
226    /// book, the spot trades and -- through the private stream -- the spot
227    /// account, while its REST calls read futures. The two now agree.
228    fn inst_type(&self) -> &'static str {
229        if self.is_futures() {
230            "USDT-FUTURES"
231        } else {
232            "SPOT"
233        }
234    }
235
236    /// Build a public Bitget client.
237    #[must_use]
238    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
239        Self::build(http, options, None)
240    }
241
242    /// Build an authenticated Bitget client (credentials must carry a passphrase).
243    #[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    /// Override the timestamp source (deterministic signing in tests).
253    #[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    /// Attach a WebSocket transport.
260    #[must_use]
261    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
262        self.ws = Some(ws);
263        self
264    }
265
266    /// The Bitget wire symbol for a canonical [`Symbol`] (`BTC/USDT` -> `BTCUSDT`).
267    #[must_use]
268    pub fn wire_symbol(symbol: &Symbol) -> String {
269        symbol.to_concatenated()
270    }
271
272    /// A ticker for `symbol`.
273    ///
274    /// # Errors
275    /// Returns an [`Error`] if the request fails or the symbol is unknown.
276    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    /// Up to `limit` candles for `symbol` at `interval` (unified). Bitget returns
308    /// oldest-first, which is already chronological.
309    ///
310    /// # Errors
311    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
312    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    /// A depth snapshot of `symbol` up to `depth` levels per side.
329    ///
330    /// # Errors
331    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
332    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    /// Subscribe to a pushed derivatives channel.
359    ///
360    /// Bitget publishes the funding rate, the mark price and the index price
361    /// inside the mix `ticker` frame -- all three in one reading, taken at one
362    /// moment -- so `Funding` and `MarkIndex` are the same subscription seen
363    /// two ways.
364    ///
365    /// `Liquidations` is refused: Bitget publishes no public stream of forced
366    /// orders. Subscribing to nothing and waiting would be the worse answer.
367    ///
368    /// # Errors
369    /// Returns an [`Error`] on a spot client, for `Liquidations`, or if the
370    /// subscription fails.
371    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    /// The current open interest (`GET /api/v2/mix/market/open-interest`).
402    ///
403    /// Polled, not pushed. The figure is in base currency.
404    ///
405    /// # Errors
406    /// Returns an [`Error`] on a spot client, if the request fails, or if the
407    /// venue returns no data point.
408    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    /// The current long/short account ratio
434    /// (`GET /api/v2/mix/market/account-long-short`), most recent point.
435    ///
436    /// Polled, not pushed. Bitget publishes the two proportions directly, as
437    /// Binance does, so nothing is derived here.
438    ///
439    /// # Errors
440    /// Returns an [`Error`] on a spot client, if the request fails, or if the
441    /// venue returns no data point.
442    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    /// Subscribe to the public trade stream for `symbol`.
468    ///
469    /// # Errors
470    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured.
471    pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
472        self.subscribe(symbol, "trade")
473    }
474
475    /// Subscribe to the order-book stream for `symbol`.
476    ///
477    /// # Errors
478    /// See [`subscribe_trades`](Self::subscribe_trades).
479    pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
480        self.subscribe(symbol, "books")
481    }
482
483    /// Subscribe to the ticker stream for `symbol`.
484    ///
485    /// # Errors
486    /// See [`subscribe_trades`](Self::subscribe_trades).
487    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    /// Drain all stream events available since the last call. Non-blocking.
517    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        // Drain the private user-data stream (orders/account channels), if open.
538        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        // A dropped private stream is re-subscribed with a fresh op:login (the
546        // signature is time-bound, so a stale replay would be rejected).
547        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    /// Open the private user-data stream (`wss://ws.bitget.com/v2/ws/private`).
571    /// Logs in with an `op:login` frame (sign = base64(HMAC-SHA256) over
572    /// `<epochSeconds>GET/user/verify`), then subscribes to the `orders` and
573    /// `account` channels. Afterwards [`poll_events`](Self::poll_events) also
574    /// surfaces the account's own [`Event::OrderUpdate`] and [`Event::BalanceUpdate`].
575    ///
576    /// A dropped private stream is re-subscribed automatically on the next
577    /// [`poll_events`](Self::poll_events); call
578    /// [`keepalive_user_data`](Self::keepalive_user_data) periodically to keep it
579    /// from being dropped for inactivity.
580    ///
581    /// # Errors
582    /// Returns [`Error::InvalidCredentials`] without credentials or a passphrase,
583    /// [`Error::NotConnected`] without a WebSocket transport, or another
584    /// [`Error`] if the request fails.
585    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        // Bitget WS login signs `<timestamp>GET/user/verify`, timestamp in seconds.
595        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    /// Send an application-level heartbeat (the `ping` text frame Bitget expects)
618    /// on the private stream so it is not dropped for inactivity. A no-op before
619    /// [`subscribe_user_data`](Self::subscribe_user_data).
620    ///
621    /// # Errors
622    /// Returns an [`Error`] if the ping cannot be sent.
623    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    /// Place an order.
631    ///
632    /// # Errors
633    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
634    /// the venue rejects it.
635    /// Refuse `reduce_only` where there is no position to reduce.
636    ///
637    /// A spot account holds balances, not positions, so a reduce-only order has
638    /// nothing to close. Dropping the flag would place an opening order where a
639    /// closing one was asked for -- the opposite trade -- so it is refused, the
640    /// way Binance's spot path and Upbit already refuse it.
641    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    /// Place a trigger order through Bitget's plan-order endpoint.
653    ///
654    /// Bitget calls a conditional order a *plan* order and serves it from its
655    /// own path -- `/api/v2/spot/trade/place-plan-order` or the mix equivalent
656    /// -- so the trigger is not a field on the ordinary call.
657    ///
658    /// `triggerType` names **which price arms the stop**, and Bitget will not
659    /// choose: `fill_price` watches the last trade, `mark_price` the mark. On a
660    /// futures position the mark is what liquidates it, so that is what a stop
661    /// there is meant to watch; on spot there is no mark, so the traded price is
662    /// the only honest answer. Leaving it out would take whichever default the
663    /// venue happens to hold.
664    ///
665    /// # Errors
666    /// Returns an [`Error`] if the trigger price is missing or the request
667    /// fails.
668    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            // The price that arms the stop: the mark on futures, because that
680            // is what a position is liquidated against; the traded price on
681            // spot, where no mark exists.
682            "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            // `normal_plan` is the ordinary trigger. The other plan types are
695            // the position-attached take-profit and stop-loss, which belong to
696            // an open position rather than to this request.
697            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        // A trigger is a different endpoint here, not a field on this one.
727        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                // A hedged account holds both sides at once, so `tradeSide`
753                // says whether this order grows a side or reduces one.
754                // `reduceOnly` is the one-way spelling of the same thing and
755                // Bitget does not accept the two together.
756                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    /// Cancel an open order by venue id.
781    ///
782    /// # Errors
783    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
784    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    /// Query a single order by venue id.
814    ///
815    /// # Errors
816    /// Returns an [`Error`] if credentials are missing or the order is unknown.
817    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            // The mix order-detail endpoint returns a single order object.
824            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    /// All open orders, optionally filtered to one `symbol`.
841    ///
842    /// # Errors
843    /// Returns an [`Error`] if credentials are missing or the request fails.
844    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            // The mix pending endpoint wraps the orders in an `entrustedList`.
858            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    /// Spot account balances.
892    ///
893    /// # Errors
894    /// Returns an [`Error`] if credentials are missing or the request fails.
895    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    /// The client order id to send for `request`: the caller's, or a generated
935    /// one.
936    ///
937    /// The generated half used to be the order's index within its batch, so the
938    /// first order of every batch was `wbatch-0`. Bitget deduplicates on this
939    /// id, so the second batch's first order looked like a repeat of the first
940    /// batch's.
941    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    /// Sign with the `ACCESS-*` headers: base64(HMAC-SHA256) over
949    /// `msTimestamp + METHOD + requestPath + body`.
950    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
992/// Map a unified interval to Bitget's `granularity` (`1min`, `1h`, `1day`).
993fn 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
1047/// The Bitget `force` value for a request. `post_only` is one of the values that
1048/// field takes, alongside `gtc`/`ioc`/`fok`, so setting both asks for two things
1049/// in one slot: that is refused rather than resolved by dropping the
1050/// time-in-force, which is what this builder used to do silently.
1051fn 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
1071/// The Bitget `stpMode` value for a self-trade-prevention policy, or `None` to omit.
1072fn 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    // Bitget candle: [ts, open, high, low, close, baseVol, quoteVol].
1150    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        // One frame, up to three answers. The mix ticker carries the funding
1257        // rate, the mark price and the index price beside the quote, all read at
1258        // the same moment -- so a `MarkIndex` from here is one observation
1259        // rather than two stitched together. Only what was subscribed to is
1260        // emitted, so a caller watching prices is not handed funding prints.
1261        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        // Private order channel: each element carries its own `instId`, which
1329        // `RawOrder` picks up as the wire symbol via the `instId` alias.
1330        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        // Private account channel: `data` is an array of per-coin balances,
1340        // emitted together as one balance-update snapshot.
1341        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    /// Bitget stamps every ticker; verified against the live endpoint.
1379    #[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    // REST reports `symbol`; the private `orders` stream reports `instId`.
1402    #[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    // Spot reports `status`; the mix (futures) order object reports `state`.
1412    #[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/// The mix (futures) pending-orders response wraps the orders in `entrustedList`.
1433#[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    /// Open positions on the USDⓈ-M futures account (`/api/v2/mix/position/all-position`).
1483    ///
1484    /// # Errors
1485    /// Returns an [`Error`] if credentials are missing or the request fails.
1486    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    /// Set the leverage for `symbol` (`/api/v2/mix/account/set-leverage`).
1501    ///
1502    /// # Errors
1503    /// Returns an [`Error`] if the leverage is rejected or the request fails.
1504    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    /// Set the margin mode for `symbol` (`/api/v2/mix/account/set-margin-mode`).
1521    ///
1522    /// # Errors
1523    /// Returns an [`Error`] if the change is rejected or the request fails.
1524    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        // Every futures order carries `marginMode`, so the account-level change
1539        // above is only half of it: without this the next order still says
1540        // `crossed` and overrides what was just set.
1541        self.margin_mode = mode;
1542        Ok(())
1543    }
1544
1545    /// Flatten the open position in `symbol` with a reduce-only market order.
1546    ///
1547    /// # Errors
1548    /// Returns [`Error::NotFound`] if there is no open position, or another
1549    /// [`Error`] if the request fails.
1550    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    /// Place several orders on one symbol in one request
1565    /// (`/api/v2/spot/trade/batch-orders`). Bitget returns separate success and
1566    /// failure lists, so a synthetic `clientOid` per index re-aligns them to each
1567    /// request's own [`Result`]. Batch requires a single symbol (that of the
1568    /// first request).
1569    ///
1570    /// # Errors
1571    /// Returns an [`Error`] if the batch request itself fails, or if called on a
1572    /// futures client (mix batch is a documented follow-up).
1573    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        // Resolved before the batch is built, so a request the venue cannot
1585        // express refuses the whole call rather than being sent weakened.
1586        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                    // The position half of the order, which this builder was
1607                    // dropping entirely: a batched reduce-only order lost the
1608                    // flag, and a hedged one never said which side it acts on.
1609                    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    /// Cancel several orders on one `symbol` in one request
1678    /// (`/api/v2/spot/trade/batch-cancel-order`).
1679    ///
1680    /// # Errors
1681    /// Returns an [`Error`] if the request fails, or if called on a futures client.
1682    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    /// Bitget spot has no in-place amend (orders are cancelled and re-placed),
1711    /// so this returns an [`Error::Exchange`].
1712    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    /// Bitget has no OCO order-list (it uses standalone plan/trigger orders), so
1731    /// this returns an [`Error::Exchange`].
1732    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    /// Bitget exposes no public WebSocket order-entry API — its WebSocket surface
1781    /// is subscription-only (market data plus the private order/account push
1782    /// channels). Orders are placed over REST, so this returns a documented
1783    /// [`Error::Exchange`].
1784    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, // flat position
1865        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        // What a geo-blocked runner receives: a status of its own and a body
1919        // that was never this venue's JSON. Read as an envelope it fails at
1920        // line 2 column 5, which says nothing about why it failed.
1921        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    /// A futures client with a mock socket.
1963    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    /// A futures client over HTTP only, for the polled reads.
1978    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    /// Bitget publishes no public stream of forced orders, so the subscription
2004    /// is refused rather than accepted and left to deliver nothing.
2005    #[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    /// One frame answers both subscriptions, because the venue reads all three
2016    /// prices at one moment and sends them together.
2017    #[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    /// A caller watching prices does not start receiving funding prints.
2053    #[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    /// Bitget publishes both proportions directly, so nothing is derived.
2088    #[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        // Newest first: the older row must not be the one read.
2103        assert_eq!(ratio.timestamp, 1_788_450_600_000);
2104    }
2105
2106    /// Every WebSocket subscription names the market the client is on.
2107    ///
2108    /// Bitget v2 serves one socket URL for every product and tells them apart by
2109    /// `instType`, the way the REST paths tell them apart by `productType`. Both
2110    /// subscribe paths hardcoded `SPOT`, so a futures client streamed the spot
2111    /// book and the spot trades while its REST calls read futures -- and its
2112    /// private stream watched the spot account, where a futures order never
2113    /// appears. Nothing failed: the venue answers a spot subscription perfectly
2114    /// well, with the wrong market's data.
2115    #[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        // And the private stream, which is where the wrong market is quietest:
2130        // a spot account simply never reports a futures order.
2131        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    /// The spot client is unchanged, which is the other half of the contract.
2148    #[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        // The first private connection closes on the first recv; the reconnect
2236        // target is a fresh open connection.
2237        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        // Two private connections (initial + reconnect), each re-signing op:login.
2245        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        // Bitget has no WebSocket order-entry API; the trait methods return a
2258        // documented error rather than faking a round trip.
2259        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    /// A trigger goes to the plan-order endpoint, and names which price arms it.
2285    ///
2286    /// `triggerType` is not inferred by the venue. On futures the mark is what
2287    /// a position is liquidated against, so that is what a stop there watches;
2288    /// on spot there is no mark, so the traded price is the only honest answer.
2289    /// Leaving it out would take whichever default the venue happens to hold.
2290    #[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        // `normal_plan` is the ordinary trigger; the other plan types belong to
2338        // an already-open position rather than to this request.
2339        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        // The batched builder sent neither `reduceOnly` nor `tradeSide`, so a
2456        // batched reduce-only order silently became an opening one.
2457        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        // Both futures order paths used to send `crossed` literally, so a client
2495        // configured for isolated margin traded cross -- silently, with the whole
2496        // account balance behind the position.
2497        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        // The default is still cross, spelled the way Bitget spells it.
2529        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        // The account-level call is only half of it: the order carries the mode
2548        // too, and a stale one overrides what was just set.
2549        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    /// Bitget stamps both public reads. Payloads captured from the live
2570    /// endpoints.
2571    #[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                // Named explicitly: this test is about aligning the venue's
2602                // success/failure lists back onto the requests, not about which
2603                // id the generator picks.
2604                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    /// `Debug` reports connection state, never secret material. A client is
2963    /// formatted into logs and error messages, so anything it prints is
2964    /// somewhere a credential must not be.
2965    #[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    /// The clock offset must reach the wire, not just the struct.
2977    ///
2978    /// A venue refuses a signed request whose timestamp is outside its own
2979    /// receive window, so a machine a few seconds off has every order rejected
2980    /// -- with a message about the window, not about the clock. This asserts the
2981    /// whole path: sync, then a signed request carrying the adjusted time.
2982    #[test]
2983    fn sync_time_shifts_signed_timestamps() {
2984        let (mut bitget, http) = signed_client(1_000_000);
2985
2986        // Shape verified against the live public endpoint on 2026-09-01.
2987        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    /// Unnamed batch orders get ids that are unique *across* batches, not only
3009    /// within one.
3010    ///
3011    /// The generated id used to be the order's index in its batch, so the first
3012    /// order of every batch was `wbatch-0`. Bitget deduplicates on the client
3013    /// id, so the second batch's first order looked like a repeat of the first
3014    /// batch's -- and the venue answered accordingly.
3015    #[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    /// A rate-limited response carries the venue's advised wait.
3039    ///
3040    /// The limit itself is recognised from the body -- a code in the error
3041    /// envelope -- while the wait arrives in the `Retry-After` header, so the
3042    /// two are read in different places. Until they were joined, every
3043    /// `RateLimited` this client raised carried `retry_after: None`: a field
3044    /// the error type documents, and that nothing in the crate ever filled.
3045    #[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}