From 7d4cdbc9d7eae329a6b63983d7d2b7c969fa211b Mon Sep 17 00:00:00 2001 From: BZ-CO <30245815+BZ-CO@users.noreply.github.com> Date: Wed, 3 Jul 2024 21:51:10 +0300 Subject: [PATCH] Add MEXC public REST endpoints (#838) * Add CryptoUtility.SecondsToPeriodInMinutesUpToHourString Convert seconds to a period string, i.e. 1m, 5m, 60m, 4h, 1d, 1W, 1M. Used on MEXC. * Add MEXC public REST endpoints * Create ExchangeMEXCAPITests.cs * Add MEXC to README.md --- README.md | 87 ++++---- .../API/Exchanges/MEXC/ExchangeMEXCAPI.cs | 211 ++++++++++++++++++ src/ExchangeSharp/Utility/CryptoUtility.cs | 36 +++ .../ExchangeMEXCAPITests.cs | 93 ++++++++ 4 files changed, 384 insertions(+), 43 deletions(-) create mode 100644 src/ExchangeSharp/API/Exchanges/MEXC/ExchangeMEXCAPI.cs create mode 100644 tests/ExchangeSharpTests/ExchangeMEXCAPITests.cs diff --git a/README.md b/README.md index 91ab16ee..b2e2a975 100644 --- a/README.md +++ b/README.md @@ -24,49 +24,50 @@ Feel free to visit the discord channel at and ch The following cryptocurrency exchanges are supported: (Web socket key: T = tickers, R = trades, B = orderbook / delta orderbook, O = private orders, U = user data) -| Exchange Name | Public REST | Private REST | Web Socket | Notes | -| ----------------------- | ----------- | ------------ | ------------- | ------------------------------------------- | -| ApolloX | x | x | T R B O U | | -| Aquanow | wip | x | | | -| Binance | x | x | T R B O U | | -| ~~Binance Jersey~~ | ~~x~~ | ~~x~~ | ~~T R B O U~~ | Ceased operations | -| Binance.US | x | x | T R B O U | | -| Binance DEX | | | R | | -| Bitbank | x | x | | | -| Bitfinex | x | x | T R O | | -| Bitflyer | | | R | | -| Bithumb | x | | R | | -| BitMEX | x | x | R O | | -| Bitstamp | x | x | R | | -| Bittrex | x | x | T R | | -| BL3P | x | x | R B | Trades stream does not send trade's ids. | -| Bleutrade | x | x | | | -| BtcTurk | | | R | | -| BTSE | x | x | | | -| Bybit | x | x | R | Has public method for Websocket Positions | -| Coinbase (Advanced) | x | x | T R O U | | -| Coincheck | | | R | | -| Coinmate | x | x | | | -| Crypto.com | | | R | | -| Digifinex | x | x | R B | | -| Dydx | | | R | | -| FTX | x | x | T R | | -| FTX.us | x | x | T R | | -| gate.io | x | x | R | | -| Gemini | x | x | T R B | | -| HitBTC | x | x | R | | -| Huobi | x | x | R B | | -| Kraken | x | x | R | Dark order symbols not supported | -| KuCoin | x | x | T R | | -| LBank | x | x | R | | -| Livecoin | x | x | | | -| NDAX | x | x | T R | | -| OKCoin | x | x | R B | | -| OKEx | x | x | T R B O | | -| Poloniex | x | x | T R B | | -| UPbit | | | R | | -| YoBit | x | x | | | -| ZB.com | wip | | R | | +| Exchange Name | Public REST | Private REST | Web Socket | Notes | +|---------------------| ----------- |--------------| ------------- | ------------------------------------------- | +| ApolloX | x | x | T R B O U | | +| Aquanow | wip | x | | | +| Binance | x | x | T R B O U | | +| ~~Binance Jersey~~ | ~~x~~ | ~~x~~ | ~~T R B O U~~ | Ceased operations | +| Binance.US | x | x | T R B O U | | +| Binance DEX | | | R | | +| Bitbank | x | x | | | +| Bitfinex | x | x | T R O | | +| Bitflyer | | | R | | +| Bithumb | x | | R | | +| BitMEX | x | x | R O | | +| Bitstamp | x | x | R | | +| Bittrex | x | x | T R | | +| BL3P | x | x | R B | Trades stream does not send trade's ids. | +| Bleutrade | x | x | | | +| BtcTurk | | | R | | +| BTSE | x | x | | | +| Bybit | x | x | R | Has public method for Websocket Positions | +| Coinbase (Advanced) | x | x | T R O U | | +| Coincheck | | | R | | +| Coinmate | x | x | | | +| Crypto.com | | | R | | +| Digifinex | x | x | R B | | +| Dydx | | | R | | +| FTX | x | x | T R | | +| FTX.us | x | x | T R | | +| gate.io | x | x | R | | +| Gemini | x | x | T R B | | +| HitBTC | x | x | R | | +| Huobi | x | x | R B | | +| Kraken | x | x | R | Dark order symbols not supported | +| KuCoin | x | x | T R | | +| LBank | x | x | R | | +| Livecoin | x | x | | | +| MEXC | x | | | | +| NDAX | x | x | T R | | +| OKCoin | x | x | R B | | +| OKEx | x | x | T R B O | | +| Poloniex | x | x | T R B | | +| UPbit | | | R | | +| YoBit | x | x | | | +| ZB.com | wip | | R | | The following cryptocurrency services are supported: diff --git a/src/ExchangeSharp/API/Exchanges/MEXC/ExchangeMEXCAPI.cs b/src/ExchangeSharp/API/Exchanges/MEXC/ExchangeMEXCAPI.cs new file mode 100644 index 00000000..7d71e4bd --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/MEXC/ExchangeMEXCAPI.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace ExchangeSharp +{ + public sealed class ExchangeMEXCAPI : ExchangeAPI + { + public override string BaseUrl { get; set; } = "https://api.mexc.com/api/v3"; + public override string BaseUrlWebSocket { get; set; } = "wss://wbs.mexc.com/ws"; + + public override string PeriodSecondsToString(int seconds) => + CryptoUtility.SecondsToPeriodInMinutesUpToHourString(seconds); + + private ExchangeMEXCAPI() + { + NonceStyle = NonceStyle.UnixMilliseconds; + MarketSymbolSeparator = string.Empty; + MarketSymbolIsUppercase = true; + RateLimit = new RateGate(20, TimeSpan.FromSeconds(2)); + } + + public override Task ExchangeMarketSymbolToGlobalMarketSymbolAsync(string marketSymbol) + { + var quoteLength = 3; + if (marketSymbol.EndsWith("USDT") || + marketSymbol.EndsWith("USDC") || + marketSymbol.EndsWith("TUSD")) + { + quoteLength = 4; + } + + var baseSymbol = marketSymbol.Substring(marketSymbol.Length - quoteLength); + + return ExchangeMarketSymbolToGlobalMarketSymbolWithSeparatorAsync( + marketSymbol.Replace(baseSymbol, "") + + GlobalMarketSymbolSeparator + + baseSymbol); + } + + protected override async Task> OnGetMarketSymbolsAsync() + { + return (await OnGetMarketSymbolsMetadataAsync()) + .Select(x => x.MarketSymbol); + } + + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { + var symbols = await MakeJsonRequestAsync("/exchangeInfo", BaseUrl); + + return (symbols["symbols"] ?? throw new ArgumentNullException()) + .Select(symbol => new ExchangeMarket() + { + MarketSymbol = symbol["symbol"].ToStringInvariant(), + IsActive = symbol["isSpotTradingAllowed"].ConvertInvariant(), + MarginEnabled = symbol["isMarginTradingAllowed"].ConvertInvariant(), + BaseCurrency = symbol["baseAsset"].ToStringInvariant(), + QuoteCurrency = symbol["quoteAsset"].ToStringInvariant(), + QuantityStepSize = symbol["baseSizePrecision"].ConvertInvariant(), + // Not 100% sure about this + PriceStepSize = + CryptoUtility.PrecisionToStepSize(symbol["quoteCommissionPrecision"].ConvertInvariant()), + MinTradeSizeInQuoteCurrency = symbol["quoteAmountPrecision"].ConvertInvariant(), + MaxTradeSizeInQuoteCurrency = symbol["maxQuoteAmount"].ConvertInvariant() + }); + } + + protected override async Task>> OnGetTickersAsync() + { + var tickers = new List>(); + var token = await MakeJsonRequestAsync("/ticker/24hr", BaseUrl); + foreach (var t in token) + { + var symbol = (t["symbol"] ?? throw new ArgumentNullException()).ToStringInvariant(); + tickers.Add(new KeyValuePair(symbol, + await this.ParseTickerAsync( + t, + symbol, + "askPrice", + "bidPrice", + "lastPrice", + "volume", + timestampType: TimestampType.UnixMilliseconds, + timestampKey: "closeTime"))); + } + + return tickers; + } + + protected override async Task OnGetTickerAsync(string marketSymbol) => + await this.ParseTickerAsync( + await MakeJsonRequestAsync($"/ticker/24hr?symbol={marketSymbol.ToUpperInvariant()}", BaseUrl), + marketSymbol, + "askPrice", + "bidPrice", + "lastPrice", + "volume", + timestampType: TimestampType.UnixMilliseconds, + timestampKey: "closeTime"); + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) + { + const int maxDepth = 5000; + const string sequenceKey = "lastUpdateId"; + marketSymbol = marketSymbol.ToUpperInvariant(); + if (string.IsNullOrEmpty(marketSymbol)) + { + throw new ArgumentOutOfRangeException(nameof(marketSymbol), "Market symbol cannot be empty."); + } + + if (maxCount > maxDepth) + { + throw new ArgumentOutOfRangeException(nameof(maxCount), $"Max order book depth is {maxDepth}"); + } + + var token = await MakeJsonRequestAsync($"/depth?symbol={marketSymbol}"); + var orderBook = token.ParseOrderBookFromJTokenArrays(sequence: sequenceKey); + orderBook.MarketSymbol = marketSymbol; + orderBook.ExchangeName = Name; + orderBook.LastUpdatedUtc = DateTime.UtcNow; + + return orderBook; + } + + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, + int? limit = null) + { + const int maxLimit = 1000; + const int defaultLimit = 500; + marketSymbol = marketSymbol.ToUpperInvariant(); + if (limit == null || limit <= 0) + { + limit = defaultLimit; + } + + if (limit > maxLimit) + { + throw new ArgumentOutOfRangeException(nameof(limit), $"Max recent trades limit is {maxLimit}"); + } + + var token = await MakeJsonRequestAsync($"/trades?symbol={marketSymbol}&limit={limit.Value}"); + return token + .Select(t => t.ParseTrade( + "qty", + "price", + "isBuyerMaker", + "time", + TimestampType.UnixMilliseconds, + "id", + "true")); + } + + protected override async Task> OnGetCandlesAsync( + string marketSymbol, + int periodSeconds, + DateTime? startDate = null, + DateTime? endDate = null, + int? limit = null) + { + var period = PeriodSecondsToString(periodSeconds); + const int maxLimit = 1000; + const int defaultLimit = 500; + if (limit == null || limit <= 0) + { + limit = defaultLimit; + } + + if (limit > maxLimit) + { + throw new ArgumentOutOfRangeException(nameof(limit), $"Max recent candlesticks limit is {maxLimit}"); + } + + + var url = $"/klines?symbol={marketSymbol}&interval={period}&limit={limit.Value}"; + if (startDate != null) + { + url = + $"{url}&startTime={new DateTimeOffset(startDate.Value).ToUnixTimeMilliseconds()}"; + } + + if (endDate != null) + { + url = $"{url}&endTime={new DateTimeOffset(endDate.Value).ToUnixTimeMilliseconds()}"; + } + + var candleResponse = await MakeJsonRequestAsync(url); + return candleResponse.Select( + cr => + this.ParseCandle( + cr, + marketSymbol, + periodSeconds, + 1, + 2, + 3, + 4, + 0, + TimestampType.UnixMilliseconds, + 5, + 7 + )); + } + } + + public partial class ExchangeName + { + public const string MEXC = "MEXC"; + } +} diff --git a/src/ExchangeSharp/Utility/CryptoUtility.cs b/src/ExchangeSharp/Utility/CryptoUtility.cs index cdc5a05a..5ab9809e 100644 --- a/src/ExchangeSharp/Utility/CryptoUtility.cs +++ b/src/ExchangeSharp/Utility/CryptoUtility.cs @@ -1309,6 +1309,42 @@ public static byte[] AesEncryption(byte[] input, byte[] password, byte[] salt) } } + /// + /// Convert seconds to a period string, i.e. 1m, 5m, 60m, 4h, 1d, 1W, 1M + /// + /// Seconds. Use 60 for minute, 3600 for hour, 3600*24 for day, 3600*24*30 for month. + /// Period string + public static string SecondsToPeriodInMinutesUpToHourString(int seconds) + { + const int minuteThreshold = 60; + const int hourThreshold = 60 * 60; + const int dayThreshold = 60 * 60 * 24; + const int weekThreshold = dayThreshold * 7; + const int monthThreshold = dayThreshold * 30; + + if (seconds >= monthThreshold) + { + return seconds / monthThreshold + "M"; + } + + if (seconds >= weekThreshold) + { + return seconds / weekThreshold + "W"; + } + + if (seconds >= dayThreshold) + { + return seconds / dayThreshold + "d"; + } + + if (seconds >= hourThreshold) + { + return seconds / 60 + "m"; + } + + return seconds / minuteThreshold + "m"; + } + /// /// Convert seconds to a period string, i.e. 5s, 1m, 2h, 3d, 1w, 1M, etc. /// diff --git a/tests/ExchangeSharpTests/ExchangeMEXCAPITests.cs b/tests/ExchangeSharpTests/ExchangeMEXCAPITests.cs new file mode 100644 index 00000000..d571e63c --- /dev/null +++ b/tests/ExchangeSharpTests/ExchangeMEXCAPITests.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using ExchangeSharp; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExchangeSharpTests; + +[TestClass] +public class MEXCAPITests +{ + private const string MarketSymbol = "ETHBTC"; + private static IExchangeAPI _api; + + [AssemblyInitialize] + public static async Task AssemblyInitialize(TestContext testContext) + { + _api = await ExchangeAPI.GetExchangeAPIAsync(); + } + + [TestMethod] + public async Task GetMarketSymbolsMetadataAsyncShouldReturnSymbols() + { + var symbols = (await _api.GetMarketSymbolsMetadataAsync()).ToImmutableArray(); + symbols.Should().NotBeNull(); + foreach (var symbol in symbols) + { + symbol.MarketSymbol.Should().NotBeNull(); + symbol.BaseCurrency.Should().NotBeNull(); + symbol.QuoteCurrency.Should().NotBeNull(); + } + } + + [TestMethod] + public async Task GetMarketSymbolsAsyncShouldReturnSymbols() + { + var symbols = (await _api.GetMarketSymbolsAsync()).ToImmutableArray(); + symbols.Should().NotBeNull(); + foreach (var symbol in symbols) + { + symbol.Should().NotBeNull(); + } + } + + [TestMethod] + public async Task GetTickersAsyncShouldReturnTickers() + { + var tickers = (await _api.GetTickersAsync()).ToImmutableArray(); + tickers.Should().NotBeNull(); + foreach (var t in tickers) + { + t.Key.Should().NotBeNull(); + t.Value.MarketSymbol.Should().NotBeNull(); + t.Value.Exchange.Should().NotBeNull(); + t.Value.Volume.Should().NotBeNull(); + } + } + + [TestMethod] + public async Task GetTickerAsyncShouldReturnTicker() + { + var ticker = await _api.GetTickerAsync(MarketSymbol); + ticker.Should().NotBeNull(); + ticker.MarketSymbol.Should().NotBeNull(); + ticker.Exchange.Should().NotBeNull(); + ticker.Volume.Should().NotBeNull(); + } + + [TestMethod] + public async Task GetOrderBookAsyncShouldReturlOrderBookData() + { + var orderBook = await _api.GetOrderBookAsync(MarketSymbol); + orderBook.MarketSymbol.Should().NotBeNullOrEmpty(); + orderBook.Asks.Should().NotBeNull(); + orderBook.Bids.Should().NotBeNull(); + } + + [TestMethod] + public async Task GetRecentTradesAsyncShouldReturnTrades() + { + var recentTrades = await _api.GetRecentTradesAsync(MarketSymbol); + recentTrades.Should().NotBeNull(); + } + + [TestMethod] + public async Task GetCandlesAsyncShouldReturnCandleData() + { + var klines = (await _api.GetCandlesAsync(MarketSymbol, 3600)).ToArray(); + klines.Should().NotBeNull(); + klines.Length.Should().NotBe(0); + } +}