Skip to main content

bat_markets_mexc/
lib.rs

1//! MEXC USDT-M futures adapter.
2
3pub mod native;
4
5use std::sync::Arc;
6
7use parking_lot::RwLock;
8use rust_decimal::Decimal;
9use serde::Deserialize;
10use serde_json::Value;
11
12use bat_markets_core::{
13    AccountSnapshot, AggressorSide, AssetCode, Balance, BatMarketsConfig, CapabilitySet,
14    ClientOrderId, CommandOperation, CommandReceipt, CommandStatus, ErrorKind, Execution,
15    FastKline, FastOrderBookDelta, FastTicker, FastTrade, FundingRate, InstrumentCatalog,
16    InstrumentId, InstrumentSpec, InstrumentStatus, InstrumentSupport, Kline, KlineInterval,
17    Leverage, Liquidity, MarginMode, MarketError, MarketType, Notional, OpenInterest, Order,
18    OrderId, OrderStatus, OrderType, Position, PositionDirection, PositionId, PositionMode, Price,
19    PrivateLaneEvent, Product, PublicLaneEvent, Quantity, Rate, RequestId, Result, Side, Ticker,
20    TimeInForce, TimestampMs, TradeId, Venue, VenueAdapter,
21};
22
23/// MEXC USDT-M futures adapter with fixture-backed protocol parsing.
24#[derive(Clone, Debug)]
25pub struct MexcLinearFuturesAdapter {
26    config: BatMarketsConfig,
27    capabilities: CapabilitySet,
28    lane_set: bat_markets_core::LaneSet,
29    instruments: Arc<RwLock<InstrumentCatalog>>,
30}
31
32impl Default for MexcLinearFuturesAdapter {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl MexcLinearFuturesAdapter {
39    #[must_use]
40    pub fn new() -> Self {
41        Self::with_config(BatMarketsConfig::new(Venue::Mexc, Product::LinearUsdt))
42    }
43
44    #[must_use]
45    pub fn with_config(config: BatMarketsConfig) -> Self {
46        Self {
47            config,
48            capabilities: mexc_capabilities(),
49            lane_set: bat_markets_core::LaneSet::linear_futures_defaults(),
50            instruments: Arc::new(RwLock::new(InstrumentCatalog::new([
51                btc_spec(),
52                eth_spec(),
53            ]))),
54        }
55    }
56
57    pub fn replace_instruments(&self, instruments: Vec<InstrumentSpec>) {
58        self.instruments.write().replace(instruments);
59    }
60
61    pub fn parse_native_public(&self, payload: &str) -> Result<native::PublicEnvelope> {
62        serde_json::from_str(payload).map_err(|error| {
63            decode_message(format!("failed to parse mexc public payload: {error}"))
64        })
65    }
66
67    pub fn parse_metadata_snapshot(&self, payload: &str) -> Result<Vec<InstrumentSpec>> {
68        let response =
69            serde_json::from_str::<native::RestResponse<Vec<native::ContractInfo>>>(payload)
70                .map_err(|error| {
71                    decode_message(format!(
72                        "failed to parse mexc contract detail response: {error}"
73                    ))
74                })?;
75        ensure_success(response.success, response.code, response.message.as_deref())?;
76
77        response
78            .data
79            .into_iter()
80            .filter(|contract| contract.quote_coin == "USDT" && contract.settle_coin == "USDT")
81            .map(contract_to_spec)
82            .collect()
83    }
84
85    pub fn parse_server_time(&self, payload: &str) -> Result<TimestampMs> {
86        let response =
87            serde_json::from_str::<native::ServerTimeResponse>(payload).map_err(|error| {
88                decode_message(format!(
89                    "failed to parse mexc server-time response: {error}"
90                ))
91            })?;
92        Ok(TimestampMs::new(response.data))
93    }
94
95    pub fn parse_ticker_snapshot(&self, payload: &str, spec: &InstrumentSpec) -> Result<Ticker> {
96        let response = serde_json::from_str::<native::RestResponse<Value>>(payload)
97            .map_err(|error| decode_message(format!("failed to parse mexc ticker: {error}")))?;
98        ensure_success(response.success, response.code, response.message.as_deref())?;
99        let data = find_symbol_object(response.data, spec.native_symbol.as_ref())?;
100        let ticker = serde_json::from_value::<native::TickerData>(data).map_err(|error| {
101            decode_message(format!("failed to decode mexc ticker data: {error}"))
102        })?;
103        ticker_to_unified(ticker, spec)
104    }
105
106    pub fn parse_tickers_snapshot(
107        &self,
108        payload: &str,
109        specs: &[InstrumentSpec],
110    ) -> Result<Vec<Ticker>> {
111        let response = serde_json::from_str::<native::RestResponse<Value>>(payload)
112            .map_err(|error| decode_message(format!("failed to parse mexc tickers: {error}")))?;
113        ensure_success(response.success, response.code, response.message.as_deref())?;
114        let items = if let Some(items) = response.data.as_array() {
115            items.clone()
116        } else if response.data.is_object() {
117            vec![response.data.clone()]
118        } else {
119            Vec::new()
120        };
121        items
122            .into_iter()
123            .filter_map(|item| {
124                let ticker = serde_json::from_value::<native::TickerData>(item).ok()?;
125                let spec = specs
126                    .iter()
127                    .find(|spec| spec.native_symbol.as_ref() == ticker.symbol.as_str())?;
128                Some(ticker_to_unified(ticker, spec))
129            })
130            .collect()
131    }
132
133    pub fn parse_trades_snapshot(
134        &self,
135        payload: &str,
136        spec: &InstrumentSpec,
137    ) -> Result<Vec<bat_markets_core::TradeTick>> {
138        let response = serde_json::from_str::<native::RestResponse<Vec<native::DealData>>>(payload)
139            .map_err(|error| {
140                decode_message(format!("failed to parse mexc deals response: {error}"))
141            })?;
142        ensure_success(response.success, response.code, response.message.as_deref())?;
143        response
144            .data
145            .into_iter()
146            .enumerate()
147            .map(|(index, deal)| trade_to_unified(deal, spec, index))
148            .collect()
149    }
150
151    pub fn parse_order_book_snapshot(
152        &self,
153        payload: &str,
154        spec: &InstrumentSpec,
155    ) -> Result<bat_markets_core::OrderBookSnapshot> {
156        let response = serde_json::from_str::<native::RestResponse<native::DepthData>>(payload)
157            .map_err(|error| {
158                decode_message(format!("failed to parse mexc order book response: {error}"))
159            })?;
160        ensure_success(response.success, response.code, response.message.as_deref())?;
161        Ok(bat_markets_core::OrderBookSnapshot {
162            instrument_id: spec.instrument_id.clone(),
163            bids: response
164                .data
165                .bids
166                .into_iter()
167                .map(level_to_book_level)
168                .collect::<Result<Vec<_>>>()?,
169            asks: response
170                .data
171                .asks
172                .into_iter()
173                .map(level_to_book_level)
174                .collect::<Result<Vec<_>>>()?,
175            event_time: TimestampMs::new(response.data.timestamp.unwrap_or_else(now_ms)),
176        })
177    }
178
179    pub fn parse_ohlcv_snapshot(
180        &self,
181        payload: &str,
182        request: &bat_markets_core::FetchOhlcvRequest,
183    ) -> Result<Vec<Kline>> {
184        let instrument_id = request.single_instrument_id()?.clone();
185        self.resolve_instrument(&instrument_id).ok_or_else(|| {
186            MarketError::new(
187                ErrorKind::Unsupported,
188                format!("unknown mexc instrument {instrument_id}"),
189            )
190        })?;
191        let interval = parse_kline_interval(&request.interval)?;
192        let response = serde_json::from_str::<native::RestResponse<native::KlineRestData>>(payload)
193            .map_err(|error| {
194                decode_message(format!("failed to parse mexc kline response: {error}"))
195            })?;
196        ensure_success(response.success, response.code, response.message.as_deref())?;
197
198        let len = response.data.time.len();
199        if response.data.open.len() != len
200            || response.data.close.len() != len
201            || response.data.high.len() != len
202            || response.data.low.len() != len
203            || response.data.vol.len() != len
204        {
205            return Err(decode_message(
206                "mexc kline arrays have inconsistent lengths".to_owned(),
207            ));
208        }
209
210        (0..len)
211            .map(|index| {
212                let open_time = response.data.time[index] * 1_000;
213                Ok(Kline {
214                    instrument_id: instrument_id.clone(),
215                    interval: interval.into(),
216                    open: Price::new(decimal_from_value(&response.data.open[index])?),
217                    high: Price::new(decimal_from_value(&response.data.high[index])?),
218                    low: Price::new(decimal_from_value(&response.data.low[index])?),
219                    close: Price::new(decimal_from_value(&response.data.close[index])?),
220                    volume: Quantity::new(decimal_from_value(&response.data.vol[index])?),
221                    open_time: TimestampMs::new(open_time),
222                    close_time: TimestampMs::new(
223                        interval.close_time_ms(open_time).unwrap_or(open_time),
224                    ),
225                    closed: true,
226                })
227            })
228            .collect()
229    }
230
231    pub fn parse_funding_rate_snapshot(
232        &self,
233        payload: &str,
234        spec: &InstrumentSpec,
235    ) -> Result<FundingRate> {
236        let response =
237            serde_json::from_str::<native::RestResponse<native::FundingRateData>>(payload)
238                .map_err(|error| {
239                    decode_message(format!("failed to parse mexc funding rate: {error}"))
240                })?;
241        ensure_success(response.success, response.code, response.message.as_deref())?;
242        Ok(FundingRate {
243            instrument_id: spec.instrument_id.clone(),
244            value: Rate::new(decimal_from_value(&response.data.funding_rate)?),
245            mark_price: None,
246            event_time: TimestampMs::new(response.data.timestamp.unwrap_or_else(now_ms)),
247        })
248    }
249
250    pub fn parse_open_interest_snapshot(
251        &self,
252        payload: &str,
253        spec: &InstrumentSpec,
254    ) -> Result<OpenInterest> {
255        let response =
256            serde_json::from_str::<native::RestResponse<Value>>(payload).map_err(|error| {
257                decode_message(format!(
258                    "failed to parse mexc open interest ticker: {error}"
259                ))
260            })?;
261        ensure_success(response.success, response.code, response.message.as_deref())?;
262        let data = find_symbol_object(response.data, spec.native_symbol.as_ref())?;
263        let ticker = serde_json::from_value::<native::TickerData>(data).map_err(|error| {
264            decode_message(format!(
265                "failed to decode mexc open interest ticker data: {error}"
266            ))
267        })?;
268        let value = ticker
269            .hold_vol
270            .as_ref()
271            .map(decimal_from_value)
272            .transpose()?
273            .map(Quantity::new)
274            .unwrap_or_else(|| Quantity::new(Decimal::ZERO));
275        Ok(OpenInterest {
276            instrument_id: spec.instrument_id.clone(),
277            value,
278            event_time: TimestampMs::new(ticker.timestamp.unwrap_or_else(now_ms)),
279        })
280    }
281
282    pub fn parse_account_snapshot(
283        &self,
284        payload: &str,
285        observed_at: TimestampMs,
286    ) -> Result<AccountSnapshot> {
287        let response = serde_json::from_str::<native::RestResponse<Vec<native::AssetData>>>(
288            payload,
289        )
290        .map_err(|error| decode_message(format!("failed to parse mexc account assets: {error}")))?;
291        ensure_success(response.success, response.code, response.message.as_deref())?;
292        let mut total_wallet = Decimal::ZERO;
293        let mut total_available = Decimal::ZERO;
294        let mut total_unrealized = Decimal::ZERO;
295        let balances = response
296            .data
297            .into_iter()
298            .map(|asset| {
299                let wallet = decimal_from_optional_value(
300                    asset.wallet_balance.as_ref().or(asset.equity.as_ref()),
301                )?;
302                let available = decimal_from_optional_value(
303                    asset
304                        .available_balance
305                        .as_ref()
306                        .or(asset.available.as_ref()),
307                )?;
308                let unrealized = decimal_from_optional_value(
309                    asset.unrealized.as_ref().or(asset.unrealised_value()),
310                )?;
311                total_wallet += wallet;
312                total_available += available;
313                total_unrealized += unrealized;
314                Ok(Balance {
315                    asset: AssetCode::from(asset.currency),
316                    wallet_balance: bat_markets_core::Amount::new(wallet),
317                    available_balance: bat_markets_core::Amount::new(available),
318                    updated_at: observed_at,
319                })
320            })
321            .collect::<Result<Vec<_>>>()?;
322
323        Ok(AccountSnapshot {
324            balances,
325            summary: Some(bat_markets_core::AccountSummary {
326                total_wallet_balance: bat_markets_core::Amount::new(total_wallet),
327                total_available_balance: bat_markets_core::Amount::new(total_available),
328                total_unrealized_pnl: bat_markets_core::Amount::new(total_unrealized),
329                updated_at: observed_at,
330            }),
331        })
332    }
333
334    pub fn parse_positions_snapshot(
335        &self,
336        payload: &str,
337        observed_at: TimestampMs,
338    ) -> Result<Vec<Position>> {
339        let response =
340            serde_json::from_str::<native::RestResponse<Vec<native::PositionData>>>(payload)
341                .map_err(|error| {
342                    decode_message(format!("failed to parse mexc positions: {error}"))
343                })?;
344        ensure_success(response.success, response.code, response.message.as_deref())?;
345        response
346            .data
347            .into_iter()
348            .filter_map(
349                |position| match decimal_from_optional_value(position.hold_vol.as_ref()) {
350                    Ok(size) if size.is_zero() => None,
351                    Ok(size) => Some(self.position_from_data(position, observed_at, size)),
352                    Err(error) => Some(Err(error)),
353                },
354            )
355            .collect()
356    }
357
358    pub fn parse_open_orders_snapshot(
359        &self,
360        payload: &str,
361        observed_at: TimestampMs,
362    ) -> Result<Vec<Order>> {
363        parse_order_list_payload(payload)?
364            .into_iter()
365            .map(|order| self.order_from_data(order, observed_at))
366            .collect()
367    }
368
369    pub fn parse_order_snapshot(&self, payload: &str, observed_at: TimestampMs) -> Result<Order> {
370        let response = serde_json::from_str::<native::RestResponse<native::OrderData>>(payload)
371            .map_err(|error| decode_message(format!("failed to parse mexc order: {error}")))?;
372        ensure_success(response.success, response.code, response.message.as_deref())?;
373        self.order_from_data(response.data, observed_at)
374    }
375
376    pub fn parse_executions_snapshot(&self, payload: &str) -> Result<Vec<Execution>> {
377        let response =
378            serde_json::from_str::<native::RestResponse<Vec<native::ExecutionData>>>(payload)
379                .map_err(|error| {
380                    decode_message(format!("failed to parse mexc executions: {error}"))
381                })?;
382        ensure_success(response.success, response.code, response.message.as_deref())?;
383        response
384            .data
385            .into_iter()
386            .map(|execution| self.execution_from_data(execution))
387            .collect()
388    }
389
390    fn position_from_data(
391        &self,
392        position: native::PositionData,
393        observed_at: TimestampMs,
394        size: Decimal,
395    ) -> Result<Position> {
396        let spec = require_native_symbol(self, &position.symbol)?;
397        Ok(Position {
398            position_id: PositionId::from(value_to_id_string(&position.position_id)),
399            instrument_id: spec.instrument_id,
400            direction: parse_position_direction(position.position_type),
401            size: Quantity::new(size),
402            entry_price: decimal_from_optional_value(position.open_avg_price.as_ref())
403                .ok()
404                .filter(|value| !value.is_zero())
405                .map(Price::new),
406            mark_price: decimal_from_optional_value(position.mark_price.as_ref())
407                .ok()
408                .map(Price::new),
409            unrealized_pnl: decimal_from_optional_value(
410                position
411                    .unrealized
412                    .as_ref()
413                    .or(position.unrealised.as_ref()),
414            )
415            .ok()
416            .map(bat_markets_core::Amount::new),
417            leverage: decimal_from_optional_value(position.leverage.as_ref())
418                .ok()
419                .map(Leverage::new),
420            margin_mode: parse_margin_mode(position.open_type),
421            position_mode: parse_position_mode(position.position_mode),
422            updated_at: TimestampMs::new(
423                position
424                    .update_time
425                    .or(position.create_time)
426                    .unwrap_or(observed_at.value()),
427            ),
428        })
429    }
430
431    fn order_from_data(&self, order: native::OrderData, observed_at: TimestampMs) -> Result<Order> {
432        let spec = require_native_symbol(self, &order.symbol)?;
433        Ok(Order {
434            order_id: OrderId::from(value_to_id_string(&order.order_id)),
435            client_order_id: order
436                .external_oid
437                .as_deref()
438                .filter(|value| !value.is_empty())
439                .map(ClientOrderId::from),
440            instrument_id: spec.instrument_id,
441            side: parse_order_side(order.side),
442            order_type: parse_order_type(order.order_type.or(order.category).unwrap_or(1)),
443            time_in_force: Some(parse_time_in_force(order.order_type.or(order.category))),
444            status: parse_order_status(order.state),
445            price: Some(Price::new(decimal_from_value(&order.price)?)),
446            quantity: Quantity::new(decimal_from_value(&order.vol)?),
447            filled_quantity: Quantity::new(decimal_from_optional_value(order.deal_vol.as_ref())?),
448            average_fill_price: decimal_from_optional_value(order.deal_avg_price.as_ref())
449                .ok()
450                .filter(|value| !value.is_zero())
451                .map(Price::new),
452            reduce_only: order.reduce_only.unwrap_or(matches!(order.side, 2 | 4)),
453            post_only: matches!(order.order_type.or(order.category), Some(2)),
454            created_at: TimestampMs::new(order.create_time.unwrap_or(observed_at.value())),
455            updated_at: TimestampMs::new(order.update_time.unwrap_or(observed_at.value())),
456            venue_status: Some(order.state.to_string().into()),
457        })
458    }
459
460    fn execution_from_data(&self, execution: native::ExecutionData) -> Result<Execution> {
461        let spec = require_native_symbol(self, &execution.symbol)?;
462        Ok(Execution {
463            execution_id: TradeId::from(value_to_id_string(&execution.id)),
464            order_id: OrderId::from(value_to_id_string(&execution.order_id)),
465            client_order_id: None,
466            instrument_id: spec.instrument_id,
467            side: parse_order_side(execution.side),
468            quantity: Quantity::new(decimal_from_value(&execution.vol)?),
469            price: Price::new(decimal_from_value(&execution.price)?),
470            fee: execution
471                .fee
472                .as_ref()
473                .map(decimal_from_value)
474                .transpose()?
475                .map(bat_markets_core::Amount::new),
476            fee_asset: execution.fee_currency.map(AssetCode::from),
477            liquidity: execution.is_taker.map(|is_taker| {
478                if is_taker {
479                    Liquidity::Taker
480                } else {
481                    Liquidity::Maker
482                }
483            }),
484            executed_at: TimestampMs::new(
485                execution
486                    .timestamp
487                    .as_ref()
488                    .and_then(value_to_i64)
489                    .unwrap_or_else(now_ms),
490            ),
491        })
492    }
493}
494
495impl VenueAdapter for MexcLinearFuturesAdapter {
496    fn venue(&self) -> Venue {
497        Venue::Mexc
498    }
499
500    fn product(&self) -> Product {
501        Product::LinearUsdt
502    }
503
504    fn config(&self) -> &BatMarketsConfig {
505        &self.config
506    }
507
508    fn capabilities(&self) -> CapabilitySet {
509        self.capabilities
510    }
511
512    fn lane_set(&self) -> bat_markets_core::LaneSet {
513        self.lane_set
514    }
515
516    fn instrument_specs(&self) -> Vec<InstrumentSpec> {
517        self.instruments.read().all().to_vec()
518    }
519
520    fn resolve_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentSpec> {
521        self.instruments.read().get(instrument_id)
522    }
523
524    fn resolve_native_symbol(&self, native_symbol: &str) -> Option<InstrumentSpec> {
525        self.instruments.read().by_native_symbol(native_symbol)
526    }
527
528    fn parse_public(&self, payload: &str) -> Result<Vec<PublicLaneEvent>> {
529        let envelope = self.parse_native_public(payload)?;
530        match envelope.channel.as_str() {
531            "push.ticker" => {
532                let ticker = serde_json::from_value::<native::TickerData>(envelope.data).map_err(
533                    |error| decode_message(format!("failed to decode mexc ticker ws: {error}")),
534                )?;
535                let spec = require_native_symbol(self, &ticker.symbol)?;
536                Ok(vec![PublicLaneEvent::Ticker(fast_ticker(ticker, &spec)?)])
537            }
538            "push.tickers" => {
539                let tickers = serde_json::from_value::<Vec<native::TickerData>>(envelope.data)
540                    .map_err(|error| {
541                        decode_message(format!("failed to decode mexc tickers ws: {error}"))
542                    })?;
543                tickers
544                    .into_iter()
545                    .map(|ticker| {
546                        let spec = require_native_symbol(self, &ticker.symbol)?;
547                        Ok(PublicLaneEvent::Ticker(fast_ticker(ticker, &spec)?))
548                    })
549                    .collect()
550            }
551            "push.deal" => {
552                let deal =
553                    serde_json::from_value::<native::DealData>(envelope.data).map_err(|error| {
554                        decode_message(format!("failed to decode mexc deal ws: {error}"))
555                    })?;
556                let symbol = envelope.symbol.as_deref().ok_or_else(|| {
557                    decode_message("mexc deal websocket payload missing symbol".to_owned())
558                })?;
559                let spec = require_native_symbol(self, symbol)?;
560                Ok(vec![PublicLaneEvent::Trade(fast_trade(deal, &spec, 0)?)])
561            }
562            "push.depth" | "push.depth.full" => {
563                let depth = serde_json::from_value::<native::DepthData>(envelope.data).map_err(
564                    |error| decode_message(format!("failed to decode mexc depth ws: {error}")),
565                )?;
566                let symbol = envelope.symbol.as_deref().ok_or_else(|| {
567                    decode_message("mexc depth websocket payload missing symbol".to_owned())
568                })?;
569                let spec = require_native_symbol(self, symbol)?;
570                Ok(vec![PublicLaneEvent::OrderBookDelta(FastOrderBookDelta {
571                    instrument_id: spec.instrument_id.clone(),
572                    bids: depth
573                        .bids
574                        .into_iter()
575                        .map(|level| fast_book_tuple(level, &spec))
576                        .collect::<Result<Vec<_>>>()?,
577                    asks: depth
578                        .asks
579                        .into_iter()
580                        .map(|level| fast_book_tuple(level, &spec))
581                        .collect::<Result<Vec<_>>>()?,
582                    event_time: TimestampMs::new(
583                        depth.timestamp.or(envelope.ts).unwrap_or_else(now_ms),
584                    ),
585                })])
586            }
587            "push.kline" => {
588                let kline = serde_json::from_value::<native::KlineData>(envelope.data).map_err(
589                    |error| decode_message(format!("failed to decode mexc kline ws: {error}")),
590                )?;
591                let symbol = kline
592                    .symbol
593                    .as_deref()
594                    .or(envelope.symbol.as_deref())
595                    .ok_or_else(|| {
596                        decode_message("mexc kline websocket payload missing symbol".to_owned())
597                    })?;
598                let spec = require_native_symbol(self, symbol)?;
599                Ok(vec![PublicLaneEvent::Kline(fast_kline(kline, &spec)?)])
600            }
601            "pong" | "rs.sub.ticker" | "rs.sub.tickers" | "rs.sub.deal" | "rs.sub.depth"
602            | "rs.sub.kline" => Ok(Vec::new()),
603            other => Err(MarketError::new(
604                ErrorKind::Unsupported,
605                format!("unsupported mexc public channel '{other}'"),
606            )
607            .with_venue(Venue::Mexc, Product::LinearUsdt)),
608        }
609    }
610
611    fn parse_private(&self, payload: &str) -> Result<Vec<PrivateLaneEvent>> {
612        let envelope = self.parse_native_public(payload)?;
613        match envelope.channel.as_str() {
614            "push.personal.asset" => {
615                let asset = serde_json::from_value::<native::AssetData>(envelope.data).map_err(
616                    |error| decode_message(format!("failed to decode mexc asset ws: {error}")),
617                )?;
618                Ok(vec![PrivateLaneEvent::Balance(Balance {
619                    asset: AssetCode::from(asset.currency),
620                    wallet_balance: bat_markets_core::Amount::new(decimal_from_optional_value(
621                        asset.wallet_balance.as_ref().or(asset.equity.as_ref()),
622                    )?),
623                    available_balance: bat_markets_core::Amount::new(decimal_from_optional_value(
624                        asset
625                            .available_balance
626                            .as_ref()
627                            .or(asset.available.as_ref()),
628                    )?),
629                    updated_at: TimestampMs::new(envelope.ts.unwrap_or_else(now_ms)),
630                })])
631            }
632            "push.personal.position" => {
633                let position = serde_json::from_value::<native::PositionData>(envelope.data)
634                    .map_err(|error| {
635                        decode_message(format!("failed to decode mexc position ws: {error}"))
636                    })?;
637                let size = decimal_from_optional_value(position.hold_vol.as_ref())?;
638                Ok(vec![PrivateLaneEvent::Position(self.position_from_data(
639                    position,
640                    TimestampMs::new(envelope.ts.unwrap_or_else(now_ms)),
641                    size,
642                )?)])
643            }
644            "push.personal.order" => {
645                let order = serde_json::from_value::<native::OrderData>(envelope.data).map_err(
646                    |error| decode_message(format!("failed to decode mexc order ws: {error}")),
647                )?;
648                Ok(vec![PrivateLaneEvent::Order(self.order_from_data(
649                    order,
650                    TimestampMs::new(envelope.ts.unwrap_or_else(now_ms)),
651                )?)])
652            }
653            "push.personal.order.deal" => {
654                let execution = serde_json::from_value::<native::ExecutionData>(envelope.data)
655                    .map_err(|error| {
656                        decode_message(format!("failed to decode mexc execution ws: {error}"))
657                    })?;
658                Ok(vec![PrivateLaneEvent::Execution(
659                    self.execution_from_data(execution)?,
660                )])
661            }
662            "rs.login" | "pong" => Ok(Vec::new()),
663            other => Err(MarketError::new(
664                ErrorKind::Unsupported,
665                format!("unsupported mexc private channel '{other}'"),
666            )
667            .with_venue(Venue::Mexc, Product::LinearUsdt)),
668        }
669    }
670
671    fn classify_command(
672        &self,
673        operation: CommandOperation,
674        payload: Option<&str>,
675        request_id: Option<RequestId>,
676    ) -> Result<CommandReceipt> {
677        let Some(payload) = payload else {
678            return Ok(command_receipt(MexcCommandReceipt {
679                operation,
680                status: CommandStatus::UnknownExecution,
681                instrument_id: None,
682                order_id: None,
683                request_id,
684                message: Some("command outcome requires reconcile".into()),
685                native_code: None,
686                retriable: true,
687            }));
688        };
689        let value = serde_json::from_str::<Value>(payload).map_err(|error| {
690            decode_message(format!("failed to parse mexc command payload: {error}"))
691        })?;
692        let success = value
693            .get("success")
694            .and_then(Value::as_bool)
695            .unwrap_or(false);
696        let code = value.get("code").and_then(value_to_i64).unwrap_or_default();
697        let message = value
698            .get("message")
699            .and_then(Value::as_str)
700            .or_else(|| value.get("msg").and_then(Value::as_str))
701            .map(Box::<str>::from);
702        let item_error = mexc_command_item_error(value.get("data"));
703        let item_error_code = item_error
704            .as_ref()
705            .map(|(error_code, _)| *error_code)
706            .unwrap_or(code);
707        let order_id = mexc_command_order_id(value.get("data"));
708        Ok(command_receipt(MexcCommandReceipt {
709            operation,
710            status: if success && code == 0 && item_error.is_none() {
711                CommandStatus::Accepted
712            } else {
713                CommandStatus::Rejected
714            },
715            instrument_id: None,
716            order_id,
717            request_id,
718            message: item_error
719                .as_ref()
720                .and_then(|(_, error_msg)| error_msg.clone())
721                .or(message)
722                .or_else(|| Some(if success { "accepted" } else { "rejected" }.into())),
723            native_code: Some(item_error_code.to_string().into()),
724            retriable: matches!(item_error_code, 500 | 501 | 510 | 603 | 2037),
725        }))
726    }
727}
728
729trait AssetDataExt {
730    fn unrealised_value(&self) -> Option<&Value>;
731}
732
733impl AssetDataExt for native::AssetData {
734    fn unrealised_value(&self) -> Option<&Value> {
735        self.unrealized.as_ref()
736    }
737}
738
739fn mexc_capabilities() -> CapabilitySet {
740    let mut capabilities = CapabilitySet::linear_futures_defaults();
741    capabilities.market.liquidations = false;
742    capabilities.trade.create = true;
743    capabilities.trade.batch_create = true;
744    capabilities.trade.amend = false;
745    capabilities.trade.cancel = true;
746    capabilities.trade.batch_cancel = true;
747    capabilities.trade.cancel_all = true;
748    capabilities.trade.validate = false;
749    capabilities.position.leverage_set = true;
750    capabilities.position.margin_mode_set = true;
751    capabilities.native.ws_order_entry = false;
752    capabilities.native.special_orders = false;
753    capabilities
754}
755
756fn contract_to_spec(contract: native::ContractInfo) -> Result<InstrumentSpec> {
757    let tick_size = decimal_from_value(&contract.price_unit)?;
758    let step_size = decimal_from_value(&contract.vol_unit)?;
759    let min_qty = decimal_from_value(&contract.min_vol)?;
760    let contract_size = decimal_from_value(&contract.contract_size)?;
761    let price_scale = contract.price_scale;
762    let qty_scale = contract.vol_scale;
763    let quote_scale = contract
764        .amount_scale
765        .unwrap_or_else(|| price_scale.saturating_add(qty_scale));
766    Ok(InstrumentSpec {
767        venue: Venue::Mexc,
768        product: Product::LinearUsdt,
769        market_type: MarketType::LinearPerpetual,
770        instrument_id: InstrumentId::from(canonical_symbol(
771            &contract.base_coin,
772            &contract.quote_coin,
773            &contract.settle_coin,
774        )),
775        canonical_symbol: canonical_symbol(
776            &contract.base_coin,
777            &contract.quote_coin,
778            &contract.settle_coin,
779        )
780        .into(),
781        native_symbol: contract.symbol.into(),
782        base: AssetCode::from(contract.base_coin),
783        quote: AssetCode::from(contract.quote_coin),
784        settle: AssetCode::from(contract.settle_coin),
785        contract_size: Quantity::new(contract_size),
786        tick_size: Price::new(tick_size),
787        step_size: Quantity::new(step_size),
788        min_qty: Quantity::new(min_qty),
789        min_notional: Notional::new(tick_size * min_qty * contract_size),
790        price_scale,
791        qty_scale,
792        quote_scale,
793        max_leverage: contract
794            .max_leverage
795            .as_ref()
796            .map(decimal_from_value)
797            .transpose()?
798            .map(Leverage::new),
799        support: InstrumentSupport {
800            public_streams: true,
801            private_trading: contract.api_allowed.unwrap_or(false),
802            leverage_set: false,
803            margin_mode_set: false,
804            funding_rate: true,
805            open_interest: true,
806        },
807        status: if contract.state.unwrap_or(0) == 0 {
808            InstrumentStatus::Active
809        } else {
810            InstrumentStatus::Halted
811        },
812    })
813}
814
815fn ticker_to_unified(ticker: native::TickerData, spec: &InstrumentSpec) -> Result<Ticker> {
816    Ok(Ticker {
817        instrument_id: spec.instrument_id.clone(),
818        last_price: Price::new(decimal_from_value(&ticker.last_price)?),
819        mark_price: ticker
820            .fair_price
821            .as_ref()
822            .map(decimal_from_value)
823            .transpose()?
824            .map(Price::new),
825        index_price: ticker
826            .index_price
827            .as_ref()
828            .map(decimal_from_value)
829            .transpose()?
830            .map(Price::new),
831        volume_24h: ticker
832            .volume24
833            .as_ref()
834            .map(decimal_from_value)
835            .transpose()?
836            .map(Quantity::new),
837        turnover_24h: ticker
838            .amount24
839            .as_ref()
840            .map(decimal_from_value)
841            .transpose()?
842            .map(Notional::new),
843        event_time: TimestampMs::new(ticker.timestamp.unwrap_or_else(now_ms)),
844    })
845}
846
847fn fast_ticker(ticker: native::TickerData, spec: &InstrumentSpec) -> Result<FastTicker> {
848    let event_time = TimestampMs::new(ticker.timestamp.unwrap_or_else(now_ms));
849    Ok(FastTicker {
850        instrument_id: spec.instrument_id.clone(),
851        last_price: Price::new(decimal_from_value(&ticker.last_price)?)
852            .quantize(spec.price_scale)?,
853        mark_price: ticker
854            .fair_price
855            .as_ref()
856            .map(decimal_from_value)
857            .transpose()?
858            .map(Price::new)
859            .map(|price| price.quantize(spec.price_scale))
860            .transpose()?,
861        index_price: ticker
862            .index_price
863            .as_ref()
864            .map(decimal_from_value)
865            .transpose()?
866            .map(Price::new)
867            .map(|price| price.quantize(spec.price_scale))
868            .transpose()?,
869        volume_24h: ticker
870            .volume24
871            .as_ref()
872            .map(decimal_from_value)
873            .transpose()?
874            .map(Quantity::new)
875            .map(|quantity| quantity.quantize(spec.qty_scale))
876            .transpose()?,
877        turnover_24h: ticker
878            .amount24
879            .as_ref()
880            .map(decimal_from_value)
881            .transpose()?
882            .map(Notional::new)
883            .map(|notional| notional.quantize(spec.quote_scale))
884            .transpose()
885            .unwrap_or(None),
886        event_time,
887    })
888}
889
890fn trade_to_unified(
891    deal: native::DealData,
892    spec: &InstrumentSpec,
893    index: usize,
894) -> Result<bat_markets_core::TradeTick> {
895    Ok(bat_markets_core::TradeTick {
896        instrument_id: spec.instrument_id.clone(),
897        trade_id: TradeId::from(format!("{}-{}", deal.t.unwrap_or_else(now_ms), index)),
898        price: Price::new(decimal_from_value(&deal.p)?),
899        quantity: Quantity::new(decimal_from_value(&deal.v)?),
900        aggressor_side: if deal.side == 1 {
901            AggressorSide::Buyer
902        } else {
903            AggressorSide::Seller
904        },
905        event_time: TimestampMs::new(deal.t.unwrap_or_else(now_ms)),
906    })
907}
908
909fn fast_trade(deal: native::DealData, spec: &InstrumentSpec, index: usize) -> Result<FastTrade> {
910    let trade = trade_to_unified(deal, spec, index)?;
911    Ok(FastTrade {
912        instrument_id: trade.instrument_id,
913        trade_id: trade.trade_id,
914        price: trade.price.quantize(spec.price_scale)?,
915        quantity: trade.quantity.quantize(spec.qty_scale)?,
916        aggressor_side: trade.aggressor_side,
917        event_time: trade.event_time,
918    })
919}
920
921fn fast_kline(kline: native::KlineData, spec: &InstrumentSpec) -> Result<FastKline> {
922    let interval = kline
923        .interval
924        .as_deref()
925        .and_then(mexc_interval_to_core)
926        .ok_or_else(|| {
927            decode_message(format!(
928                "unsupported or missing mexc kline interval '{}'",
929                kline.interval.as_deref().unwrap_or("<missing>")
930            ))
931        })?;
932    let open_time = kline.t.unwrap_or_else(|| now_ms() / 1_000) * 1_000;
933    Ok(FastKline {
934        instrument_id: spec.instrument_id.clone(),
935        interval: interval.into(),
936        open: Price::new(decimal_from_value(&kline.o)?).quantize(spec.price_scale)?,
937        high: Price::new(decimal_from_value(&kline.h)?).quantize(spec.price_scale)?,
938        low: Price::new(decimal_from_value(&kline.l)?).quantize(spec.price_scale)?,
939        close: Price::new(decimal_from_value(&kline.c)?).quantize(spec.price_scale)?,
940        volume: Quantity::new(decimal_from_optional_value(
941            kline.a.as_ref().or(kline.q.as_ref()),
942        )?)
943        .quantize(spec.qty_scale)?,
944        open_time: TimestampMs::new(open_time),
945        close_time: TimestampMs::new(interval.close_time_ms(open_time).unwrap_or(open_time)),
946        closed: false,
947    })
948}
949
950fn level_to_book_level(level: Vec<Value>) -> Result<bat_markets_core::OrderBookLevel> {
951    if level.len() < 2 {
952        return Err(decode_message(
953            "mexc depth level has fewer than two fields".to_owned(),
954        ));
955    }
956    Ok(bat_markets_core::OrderBookLevel {
957        price: Price::new(decimal_from_value(&level[0])?),
958        quantity: Quantity::new(decimal_from_value(&level[1])?),
959    })
960}
961
962fn fast_book_tuple(
963    level: Vec<Value>,
964    spec: &InstrumentSpec,
965) -> Result<(bat_markets_core::FastPrice, bat_markets_core::FastQuantity)> {
966    let level = level_to_book_level(level)?;
967    Ok((
968        level.price.quantize(spec.price_scale)?,
969        level.quantity.quantize(spec.qty_scale)?,
970    ))
971}
972
973fn parse_order_list_payload(payload: &str) -> Result<Vec<native::OrderData>> {
974    #[derive(Deserialize)]
975    struct Page {
976        #[serde(default)]
977        result_list: Vec<native::OrderData>,
978    }
979    let response = serde_json::from_str::<native::RestResponse<Value>>(payload)
980        .map_err(|error| decode_message(format!("failed to parse mexc order list: {error}")))?;
981    ensure_success(response.success, response.code, response.message.as_deref())?;
982    if response.data.is_array() {
983        return serde_json::from_value(response.data)
984            .map_err(|error| decode_message(format!("failed to decode mexc order list: {error}")));
985    }
986    let page = serde_json::from_value::<Page>(response.data)
987        .map_err(|error| decode_message(format!("failed to decode mexc order page: {error}")))?;
988    Ok(page.result_list)
989}
990
991fn find_symbol_object(data: Value, native_symbol: &str) -> Result<Value> {
992    if let Some(items) = data.as_array() {
993        return items
994            .iter()
995            .find(|item| item.get("symbol").and_then(Value::as_str) == Some(native_symbol))
996            .cloned()
997            .ok_or_else(|| {
998                decode_message(format!("mexc ticker response missing {native_symbol}"))
999            });
1000    }
1001    Ok(data)
1002}
1003
1004fn parse_kline_interval(raw: &str) -> Result<KlineInterval> {
1005    KlineInterval::parse(raw)
1006        .or_else(|| mexc_interval_to_core(raw))
1007        .ok_or_else(|| {
1008            MarketError::new(
1009                ErrorKind::Unsupported,
1010                format!("unsupported mexc interval '{raw}'"),
1011            )
1012        })
1013}
1014
1015fn mexc_interval_to_core(raw: &str) -> Option<KlineInterval> {
1016    match raw {
1017        "Min1" => Some(KlineInterval::Minute1),
1018        "Min5" => Some(KlineInterval::Minute5),
1019        "Min15" => Some(KlineInterval::Minute15),
1020        "Min30" => Some(KlineInterval::Minute30),
1021        "Min60" => Some(KlineInterval::Hour1),
1022        "Hour4" => Some(KlineInterval::Hour4),
1023        "Day1" => Some(KlineInterval::Day1),
1024        "Week1" => Some(KlineInterval::Week1),
1025        "Month1" => Some(KlineInterval::Month1),
1026        _ => None,
1027    }
1028}
1029
1030fn parse_order_side(value: i64) -> Side {
1031    match value {
1032        1 | 4 => Side::Buy,
1033        _ => Side::Sell,
1034    }
1035}
1036
1037fn parse_position_direction(value: i64) -> PositionDirection {
1038    match value {
1039        1 => PositionDirection::Long,
1040        2 => PositionDirection::Short,
1041        _ => PositionDirection::Flat,
1042    }
1043}
1044
1045fn parse_order_type(value: i64) -> OrderType {
1046    match value {
1047        5 | 6 => OrderType::Market,
1048        _ => OrderType::Limit,
1049    }
1050}
1051
1052fn parse_time_in_force(value: Option<i64>) -> TimeInForce {
1053    match value {
1054        Some(2) => TimeInForce::PostOnly,
1055        Some(3) => TimeInForce::Ioc,
1056        Some(4) => TimeInForce::Fok,
1057        _ => TimeInForce::Gtc,
1058    }
1059}
1060
1061fn parse_order_status(value: i64) -> OrderStatus {
1062    match value {
1063        1 | 2 => OrderStatus::New,
1064        3 => OrderStatus::Filled,
1065        4 => OrderStatus::Canceled,
1066        5 => OrderStatus::Rejected,
1067        _ => OrderStatus::Expired,
1068    }
1069}
1070
1071fn parse_margin_mode(value: Option<i64>) -> MarginMode {
1072    match value {
1073        Some(1) => MarginMode::Isolated,
1074        _ => MarginMode::Cross,
1075    }
1076}
1077
1078fn parse_position_mode(value: Option<i64>) -> PositionMode {
1079    match value {
1080        Some(1) => PositionMode::Hedge,
1081        _ => PositionMode::OneWay,
1082    }
1083}
1084
1085struct MexcCommandReceipt {
1086    operation: CommandOperation,
1087    status: CommandStatus,
1088    instrument_id: Option<InstrumentId>,
1089    order_id: Option<OrderId>,
1090    request_id: Option<RequestId>,
1091    message: Option<Box<str>>,
1092    native_code: Option<Box<str>>,
1093    retriable: bool,
1094}
1095
1096fn command_receipt(parts: MexcCommandReceipt) -> CommandReceipt {
1097    CommandReceipt {
1098        operation: parts.operation,
1099        status: parts.status,
1100        venue: Venue::Mexc,
1101        product: Product::LinearUsdt,
1102        instrument_id: parts.instrument_id,
1103        order_id: parts.order_id,
1104        client_order_id: None,
1105        request_id: parts.request_id,
1106        message: parts.message,
1107        native_code: parts.native_code,
1108        retriable: parts.retriable,
1109    }
1110}
1111
1112fn require_native_symbol(
1113    adapter: &MexcLinearFuturesAdapter,
1114    symbol: &str,
1115) -> Result<InstrumentSpec> {
1116    adapter.resolve_native_symbol(symbol).ok_or_else(|| {
1117        MarketError::new(
1118            ErrorKind::Unsupported,
1119            format!("unknown mexc symbol {symbol}"),
1120        )
1121        .with_venue(Venue::Mexc, Product::LinearUsdt)
1122    })
1123}
1124
1125fn decimal_from_optional_value(value: Option<&Value>) -> Result<Decimal> {
1126    value
1127        .map(decimal_from_value)
1128        .transpose()
1129        .map(|value| value.unwrap_or(Decimal::ZERO))
1130}
1131
1132fn decimal_from_value(value: &Value) -> Result<Decimal> {
1133    match value {
1134        Value::Number(number) => decimal_from_str(&number.to_string()),
1135        Value::String(value) => decimal_from_str(value),
1136        Value::Null => Ok(Decimal::ZERO),
1137        other => decimal_from_str(&other.to_string()),
1138    }
1139    .map_err(|error| decode_message(format!("invalid mexc decimal '{value}': {error}")))
1140}
1141
1142fn decimal_from_str(value: &str) -> std::result::Result<Decimal, String> {
1143    let Some((mantissa, exponent)) = value.split_once(['e', 'E']) else {
1144        return Decimal::from_str_exact(value).map_err(|error| error.to_string());
1145    };
1146    let mut parsed = Decimal::from_str_exact(mantissa).map_err(|error| error.to_string())?;
1147    let exponent = exponent
1148        .parse::<i32>()
1149        .map_err(|error| format!("invalid exponent: {error}"))?;
1150    let ten = Decimal::from(10);
1151    for _ in 0..exponent.unsigned_abs() {
1152        if exponent >= 0 {
1153            parsed *= ten;
1154        } else {
1155            parsed /= ten;
1156        }
1157    }
1158    Ok(parsed)
1159}
1160
1161fn value_to_id_string(value: &Value) -> String {
1162    match value {
1163        Value::String(value) => value.clone(),
1164        Value::Number(value) => value.to_string(),
1165        Value::Null => String::new(),
1166        value => value.to_string(),
1167    }
1168}
1169
1170fn value_to_i64(value: &Value) -> Option<i64> {
1171    value.as_i64().or_else(|| value.as_str()?.parse().ok())
1172}
1173
1174fn mexc_command_order_id(data: Option<&Value>) -> Option<OrderId> {
1175    let value = match data? {
1176        Value::Array(items) => items.first()?.get("orderId")?,
1177        Value::Object(map) => map.get("orderId").or_else(|| map.get("order_id"))?,
1178        value => value,
1179    };
1180    Some(value_to_id_string(value))
1181        .filter(|id| !id.is_empty() && id != "null")
1182        .map(OrderId::from)
1183}
1184
1185fn mexc_command_item_error(data: Option<&Value>) -> Option<(i64, Option<Box<str>>)> {
1186    let item = match data? {
1187        Value::Array(items) => items.first()?,
1188        Value::Object(_) => data?,
1189        _ => return None,
1190    };
1191    let error_code = item
1192        .get("errorCode")
1193        .or_else(|| item.get("error_code"))
1194        .and_then(value_to_i64)?;
1195    if error_code == 0 {
1196        return None;
1197    }
1198    let error_msg = item
1199        .get("errorMsg")
1200        .or_else(|| item.get("error_msg"))
1201        .and_then(Value::as_str)
1202        .map(Box::<str>::from);
1203    Some((error_code, error_msg))
1204}
1205
1206fn canonical_symbol(base: &str, quote: &str, settle: &str) -> String {
1207    format!("{base}/{quote}:{settle}")
1208}
1209
1210fn ensure_success(success: bool, code: i64, message: Option<&str>) -> Result<()> {
1211    if success && code == 0 {
1212        return Ok(());
1213    }
1214    Err(MarketError::new(
1215        ErrorKind::ExchangeReject,
1216        message.unwrap_or("mexc request rejected"),
1217    )
1218    .with_venue(Venue::Mexc, Product::LinearUsdt))
1219}
1220
1221fn decode_message(message: String) -> MarketError {
1222    MarketError::new(ErrorKind::DecodeError, message).with_venue(Venue::Mexc, Product::LinearUsdt)
1223}
1224
1225fn now_ms() -> i64 {
1226    std::time::SystemTime::now()
1227        .duration_since(std::time::UNIX_EPOCH)
1228        .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
1229        .unwrap_or_default()
1230}
1231
1232fn btc_spec() -> InstrumentSpec {
1233    fixture_spec("BTC", 2, 0, "0.1", "1", 125)
1234}
1235
1236fn eth_spec() -> InstrumentSpec {
1237    fixture_spec("ETH", 2, 0, "0.01", "1", 100)
1238}
1239
1240fn fixture_spec(
1241    base: &str,
1242    price_scale: u32,
1243    qty_scale: u32,
1244    tick: &str,
1245    step: &str,
1246    leverage: i64,
1247) -> InstrumentSpec {
1248    let native = format!("{base}_USDT");
1249    InstrumentSpec {
1250        venue: Venue::Mexc,
1251        product: Product::LinearUsdt,
1252        market_type: MarketType::LinearPerpetual,
1253        instrument_id: InstrumentId::from(canonical_symbol(base, "USDT", "USDT")),
1254        canonical_symbol: canonical_symbol(base, "USDT", "USDT").into(),
1255        native_symbol: native.into(),
1256        base: AssetCode::from(base),
1257        quote: AssetCode::from("USDT"),
1258        settle: AssetCode::from("USDT"),
1259        contract_size: Quantity::new(Decimal::ONE),
1260        tick_size: Price::new(Decimal::new(
1261            tick.replace('.', "").parse::<i64>().unwrap_or(1),
1262            decimal_places(tick),
1263        )),
1264        step_size: Quantity::new(Decimal::new(
1265            step.replace('.', "").parse::<i64>().unwrap_or(1),
1266            decimal_places(step),
1267        )),
1268        min_qty: Quantity::new(Decimal::new(
1269            step.replace('.', "").parse::<i64>().unwrap_or(1),
1270            decimal_places(step),
1271        )),
1272        min_notional: Notional::new(Decimal::new(
1273            tick.replace('.', "").parse::<i64>().unwrap_or(1),
1274            decimal_places(tick),
1275        )),
1276        price_scale,
1277        qty_scale,
1278        quote_scale: price_scale + qty_scale,
1279        max_leverage: Some(Leverage::new(Decimal::from(leverage))),
1280        support: InstrumentSupport {
1281            public_streams: true,
1282            private_trading: false,
1283            leverage_set: false,
1284            margin_mode_set: false,
1285            funding_rate: true,
1286            open_interest: true,
1287        },
1288        status: InstrumentStatus::Active,
1289    }
1290}
1291
1292fn decimal_places(value: &str) -> u32 {
1293    value
1294        .split_once('.')
1295        .map(|(_, fraction)| fraction.len() as u32)
1296        .unwrap_or(0)
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301    use super::*;
1302    use bat_markets_core::{PrivateLaneEvent, PublicLaneEvent};
1303
1304    const CONTRACT_DETAIL: &str = include_str!("../../../fixtures/mexc/contract_detail.json");
1305    const PUBLIC_TICKER: &str = include_str!("../../../fixtures/mexc/public_ticker.json");
1306    const PUBLIC_DEPTH: &str = include_str!("../../../fixtures/mexc/public_depth.json");
1307    const PUBLIC_KLINE: &str = include_str!("../../../fixtures/mexc/public_kline.json");
1308    const PRIVATE_ORDER: &str = include_str!("../../../fixtures/mexc/private_order.json");
1309
1310    #[test]
1311    fn parse_mexc_contract_metadata() {
1312        let adapter = MexcLinearFuturesAdapter::new();
1313        let specs = adapter
1314            .parse_metadata_snapshot(CONTRACT_DETAIL)
1315            .unwrap_or_else(|error| panic!("mexc metadata should parse: {error}"));
1316        assert_eq!(specs[0].native_symbol.as_ref(), "BTC_USDT");
1317        assert_eq!(specs[0].instrument_id.as_ref(), "BTC/USDT:USDT");
1318        assert_eq!(specs[0].tick_size.to_string(), "0.1");
1319        assert!(!specs[0].support.private_trading);
1320    }
1321
1322    #[test]
1323    fn parse_mexc_scientific_decimal_values() {
1324        let value = serde_json::json!(1e-8);
1325        let parsed = decimal_from_value(&value)
1326            .unwrap_or_else(|error| panic!("scientific decimal should parse: {error}"));
1327        assert_eq!(parsed, Decimal::new(1, 8));
1328    }
1329
1330    #[test]
1331    fn parse_mexc_public_ticker_ws() {
1332        let adapter = MexcLinearFuturesAdapter::new();
1333        let events = adapter
1334            .parse_public(PUBLIC_TICKER)
1335            .unwrap_or_else(|error| panic!("mexc ticker should parse: {error}"));
1336        let PublicLaneEvent::Ticker(ticker) = &events[0] else {
1337            panic!("expected ticker");
1338        };
1339        assert_eq!(ticker.instrument_id.as_ref(), "BTC/USDT:USDT");
1340        assert_eq!(ticker.last_price.value(), 6543210);
1341    }
1342
1343    #[test]
1344    fn parse_mexc_open_interest_uses_hold_volume() {
1345        let adapter = MexcLinearFuturesAdapter::new();
1346        let spec = adapter
1347            .resolve_native_symbol("BTC_USDT")
1348            .unwrap_or_else(|| panic!("mexc fixture symbol should exist"));
1349        let payload = r#"{
1350            "success": true,
1351            "code": 0,
1352            "data": {
1353                "symbol": "BTC_USDT",
1354                "lastPrice": 65432.1,
1355                "volume24": 1200,
1356                "holdVol": 4321,
1357                "timestamp": 1761879567135
1358            }
1359        }"#;
1360        let open_interest = adapter
1361            .parse_open_interest_snapshot(payload, &spec)
1362            .unwrap_or_else(|error| panic!("mexc open interest should parse: {error}"));
1363        assert_eq!(open_interest.value.value(), Decimal::new(4321, 0));
1364    }
1365
1366    #[test]
1367    fn parse_mexc_public_depth_ws() {
1368        let adapter = MexcLinearFuturesAdapter::new();
1369        let events = adapter
1370            .parse_public(PUBLIC_DEPTH)
1371            .unwrap_or_else(|error| panic!("mexc depth should parse: {error}"));
1372        let PublicLaneEvent::OrderBookDelta(delta) = &events[0] else {
1373            panic!("expected depth");
1374        };
1375        assert_eq!(delta.bids[0].0.value(), 6543200);
1376        assert_eq!(delta.asks[0].1.value(), 2);
1377    }
1378
1379    #[test]
1380    fn parse_mexc_public_kline_ws() {
1381        let adapter = MexcLinearFuturesAdapter::new();
1382        let events = adapter
1383            .parse_public(PUBLIC_KLINE)
1384            .unwrap_or_else(|error| panic!("mexc kline should parse: {error}"));
1385        let PublicLaneEvent::Kline(kline) = &events[0] else {
1386            panic!("expected kline");
1387        };
1388        assert_eq!(kline.interval.as_ref(), "1m");
1389        assert_eq!(kline.close.value(), 6544500);
1390    }
1391
1392    #[test]
1393    fn parse_mexc_private_order_ws() {
1394        let adapter = MexcLinearFuturesAdapter::new();
1395        let events = adapter
1396            .parse_private(PRIVATE_ORDER)
1397            .unwrap_or_else(|error| panic!("mexc private order should parse: {error}"));
1398        let PrivateLaneEvent::Order(order) = &events[0] else {
1399            panic!("expected order");
1400        };
1401        assert_eq!(order.order_id.as_ref(), "123456789");
1402        assert_eq!(order.status, OrderStatus::New);
1403        assert_eq!(order.side, Side::Buy);
1404    }
1405
1406    #[test]
1407    fn mexc_write_capabilities_expose_documented_rest_order_paths() {
1408        let adapter = MexcLinearFuturesAdapter::new();
1409        let capabilities = adapter.capabilities();
1410        assert!(capabilities.trade.create);
1411        assert!(capabilities.trade.batch_create);
1412        assert!(capabilities.trade.cancel);
1413        assert!(capabilities.trade.batch_cancel);
1414        assert!(capabilities.trade.cancel_all);
1415        assert!(capabilities.position.leverage_set);
1416        assert!(capabilities.position.margin_mode_set);
1417        assert!(!capabilities.trade.amend);
1418        assert!(!capabilities.trade.validate);
1419        assert!(!capabilities.native.ws_order_entry);
1420        assert!(capabilities.trade.get);
1421        assert!(capabilities.market.public_streams);
1422    }
1423
1424    #[test]
1425    fn mexc_command_classification_reads_nested_order_id_and_item_errors() {
1426        let adapter = MexcLinearFuturesAdapter::new();
1427        let accepted = adapter
1428            .classify_command(
1429                CommandOperation::CreateOrder,
1430                Some(r#"{"success":true,"code":0,"data":{"orderId":"739113577038255616","ts":1761888808839}}"#),
1431                None,
1432            )
1433            .unwrap_or_else(|error| panic!("mexc command should parse: {error}"));
1434        assert_eq!(accepted.status, CommandStatus::Accepted);
1435        assert_eq!(
1436            accepted.order_id.as_ref().map(OrderId::as_ref),
1437            Some("739113577038255616")
1438        );
1439
1440        let rejected = adapter
1441            .classify_command(
1442                CommandOperation::CancelOrder,
1443                Some(r#"{"success":true,"code":0,"data":[{"orderId":101716841474621953,"errorCode":2040,"errorMsg":"order not exist"}]}"#),
1444                None,
1445            )
1446            .unwrap_or_else(|error| panic!("mexc cancel command should parse: {error}"));
1447        assert_eq!(rejected.status, CommandStatus::Rejected);
1448        assert_eq!(rejected.native_code.as_deref(), Some("2040"));
1449    }
1450}