diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitInverseAPI.cs b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitInverseAPI.cs
new file mode 100644
index 00000000..82fc49fe
--- /dev/null
+++ b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitInverseAPI.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using ExchangeSharp.Bybit;
+
+namespace ExchangeSharp
+{
+ [ApiName(ExchangeName.Bybit)]
+ public class ExchangeBybitInverseAPI : ExchangeBybitV5Base
+ {
+ protected override MarketCategory MarketCategory => MarketCategory.Inverse;
+ public override string BaseUrlWebSocket => "wss://stream.bybit.com/v5/public/inverse";
+ public ExchangeBybitInverseAPI()
+ {
+ }
+ public ExchangeBybitInverseAPI(bool isUnifiedAccount)
+ {
+ IsUnifiedAccount = isUnifiedAccount;
+ }
+ }
+}
diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitLinearAPI.cs b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitLinearAPI.cs
new file mode 100644
index 00000000..723827a1
--- /dev/null
+++ b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitLinearAPI.cs
@@ -0,0 +1,21 @@
+using ExchangeSharp.Bybit;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ExchangeSharp
+{
+ [ApiName(ExchangeName.Bybit)]
+ public class ExchangeBybitLinearAPI : ExchangeBybitV5Base
+ {
+ protected override MarketCategory MarketCategory => MarketCategory.Linear;
+ public override string BaseUrlWebSocket => "wss://stream.bybit.com/v5/public/linear";
+ public ExchangeBybitLinearAPI()
+ {
+ }
+ public ExchangeBybitLinearAPI(bool isUnifiedAccount)
+ {
+ IsUnifiedAccount = isUnifiedAccount;
+ }
+ }
+}
diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitOptionAPI.cs b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitOptionAPI.cs
new file mode 100644
index 00000000..1cd1252c
--- /dev/null
+++ b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitOptionAPI.cs
@@ -0,0 +1,21 @@
+using ExchangeSharp.Bybit;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ExchangeSharp
+{
+ [ApiName(ExchangeName.Bybit)]
+ public class ExchangeBybitOptionAPI : ExchangeBybitV5Base
+ {
+ protected override MarketCategory MarketCategory => MarketCategory.Option;
+ public override string BaseUrlWebSocket => "wss://stream.bybit.com/v5/public/option";
+ public ExchangeBybitOptionAPI()
+ {
+ }
+ public ExchangeBybitOptionAPI(bool isUnifiedAccount)
+ {
+ IsUnifiedAccount = isUnifiedAccount;
+ }
+ }
+}
diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitSpotAPI.cs b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitSpotAPI.cs
new file mode 100644
index 00000000..90bb9c9b
--- /dev/null
+++ b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitSpotAPI.cs
@@ -0,0 +1,21 @@
+using ExchangeSharp.Bybit;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ExchangeSharp
+{
+ [ApiName(ExchangeName.Bybit)]
+ public class ExchangeBybitSpotAPI : ExchangeBybitV5Base
+ {
+ protected override MarketCategory MarketCategory => MarketCategory.Spot;
+ public override string BaseUrlWebSocket => "wss://stream.bybit.com/v5/public/spot";
+ public ExchangeBybitSpotAPI()
+ {
+ }
+ public ExchangeBybitSpotAPI(bool isUnified)
+ {
+ IsUnifiedAccount = isUnified;
+ }
+ }
+}
diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitV5Base.cs b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitV5Base.cs
new file mode 100644
index 00000000..6403af81
--- /dev/null
+++ b/src/ExchangeSharp/API/Exchanges/Bybit/ExchangeBybitV5Base.cs
@@ -0,0 +1,756 @@
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using ExchangeSharp.Bybit;
+using System.Threading.Tasks;
+using System.Linq;
+
+namespace ExchangeSharp
+{
+ public abstract class ExchangeBybitV5Base : ExchangeAPI
+ {
+ public override string BaseUrl { get; set; } = "https://api.bybit.com";
+ public override string BaseUrlPrivateWebSocket => "wss://stream.bybit.com/v5/private";
+
+ ///
+ /// Can be one of: linear, inverse, option, spot
+ ///
+ protected virtual MarketCategory MarketCategory => throw new NotImplementedException("MarketCategory");
+
+ ///
+ /// Account status (is account Unified) needed in some private end-points (e.g. OnGetAmountsAvailableToTradeAsync or GetRecentOrderAsync).
+ /// Better be set with constructor. Also it can be set explicitly or GetAccountInfo() can be used to get the account status.
+ ///
+ public virtual bool? IsUnifiedAccount { get; set; }
+
+ public ExchangeBybitV5Base()
+ {
+ MarketSymbolIsUppercase = true;
+ NonceStyle = NonceStyle.UnixMilliseconds;
+ NonceOffset = TimeSpan.FromSeconds(1.0);
+ NonceEndPoint = "/v3/public/time";
+ NonceEndPointField = "timeNano";
+ RequestContentType = "application/json";
+ WebSocketOrderBookType = WebSocketOrderBookType.FullBookFirstThenDeltas;
+ RateLimit = new RateGate(10, TimeSpan.FromSeconds(1));
+ RequestWindow = TimeSpan.FromSeconds(15);
+ }
+
+ protected override async Task OnGetNonceOffset()
+ {
+ /*
+ * https://bybit-exchange.github.io/docs/v5/intro#parameters-for-authenticated-endpoints
+ Please make sure that the timestamp parameter adheres to the following rule:
+ server_time - recv_window <= timestamp < server_time + 1000
+ */
+ try
+ {
+ JToken token = await MakeJsonRequestAsync(NonceEndPoint!);
+ JToken value = token[NonceEndPointField];
+
+ DateTime serverDate = value.ConvertInvariant().UnixTimeStampToDateTimeNanoseconds();
+ NonceOffset = (CryptoUtility.UtcNow - serverDate) + TimeSpan.FromSeconds(1);
+ Logger.Info($"Nonce offset set for {Name}: {NonceOffset.TotalMilliseconds} milisec");
+ }
+ catch
+ {
+ // if this fails we don't want to crash, just run without a nonce
+ Logger.Warn($"Failed to get nonce offset for {Name}");
+ }
+ }
+ protected override async Task> GetNoncePayloadAsync()
+ {
+ return new Dictionary
+ {
+ ["nonce"] = await GenerateNonceAsync(),
+ ["category"] = MarketCategory.ToStringLowerInvariant()
+ };
+ }
+ protected override Uri ProcessRequestUrl(UriBuilder url, Dictionary payload, string method)
+ {
+ if (payload != null && payload.Count > 1 && method == "GET")
+ {
+ string query = CryptoUtility.GetFormForPayload(payload, includeNonce: false, orderByKey: true, formEncode: false);
+ query = query.Replace("=True", "=true").Replace("=False", "=false");
+ url.Query = query;
+ }
+ return url.Uri;
+ }
+ protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload)
+ {
+ if (CanMakeAuthenticatedRequest(payload))
+ {
+ string nonce = payload["nonce"].ToStringInvariant();
+ payload.Remove("nonce");
+ var recvWindow = (int)RequestWindow.TotalMilliseconds;
+ string toSign = $"{nonce}{PublicApiKey.ToUnsecureString()}{recvWindow}";
+ string json = string.Empty;
+ if (request.Method == "POST")
+ {
+ json = JsonConvert.SerializeObject(payload);
+ toSign += json;
+ }
+ else if (request.Method == "GET" && !string.IsNullOrEmpty(request.RequestUri.Query))
+ {
+ toSign += request.RequestUri.Query.Substring(1);
+ }
+ string signature = CryptoUtility.SHA256Sign(toSign, PrivateApiKey.ToUnsecureString());
+ request.AddHeader("X-BAPI-SIGN", signature);
+ request.AddHeader("X-BAPI-API-KEY", PublicApiKey.ToUnsecureString());
+ request.AddHeader("X-BAPI-TIMESTAMP", nonce);
+ request.AddHeader("X-BAPI-RECV-WINDOW", recvWindow.ToStringInvariant());
+ if (request.Method == "POST")
+ {
+ await CryptoUtility.WriteToRequestAsync(request, json);
+ }
+ }
+ }
+ protected override JToken CheckJsonResponse(JToken result)
+ {
+ int retCode = result["retCode"].ConvertInvariant();
+ if (retCode != 0)
+ {
+ string message = result["retMsg"].ToStringInvariant();
+ throw new APIException($"{{code: {retCode}, message: '{message}'}}");
+ }
+ return result["result"];
+ }
+
+ #region Public
+
+ protected internal override async Task> OnGetMarketSymbolsMetadataAsync()
+ {
+ List markets = new List();
+ var responseToken = await MakeJsonRequestAsync($"/v5/market/instruments-info?category={MarketCategory.ToStringLowerInvariant()}");
+
+ foreach (var marketJson in responseToken["list"])
+ {
+ var priceFilter = marketJson["priceFilter"];
+ var sizeFilter = marketJson["lotSizeFilter"];
+ bool isInverse = MarketCategory == MarketCategory.Inverse;
+ var market = new ExchangeMarket()
+ {
+ MarketSymbol = marketJson["symbol"].ToStringInvariant(),
+ BaseCurrency = marketJson["baseCoin"].ToStringInvariant(),
+ QuoteCurrency = marketJson["quoteCoin"].ToStringInvariant(),
+ QuantityStepSize = sizeFilter["qtyStep"].ConvertInvariant(),
+ MinPrice = priceFilter["minPrice"].ConvertInvariant(),
+ MaxPrice = priceFilter["maxPrice"].ConvertInvariant(),
+ PriceStepSize = priceFilter["tickSize"].ConvertInvariant(),
+ IsActive = marketJson["status"] == null || marketJson["status"].ToStringLowerInvariant() == "trading"
+ };
+ if (isInverse)
+ {
+ market.MinTradeSizeInQuoteCurrency = sizeFilter["minOrderQty"].ConvertInvariant();
+ market.MaxTradeSizeInQuoteCurrency = sizeFilter["maxOrderQty"].ConvertInvariant();
+ }
+ else
+ {
+ market.MinTradeSize = sizeFilter["minOrderQty"].ConvertInvariant();
+ market.MaxTradeSize = sizeFilter["maxOrderQty"].ConvertInvariant();
+ }
+ markets.Add(market);
+ }
+ return markets;
+ }
+ protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null)
+ {
+ string url = "/v5/market/kline";
+ int maxLimit = 200;
+ limit ??= maxLimit;
+ if (limit.Value > maxLimit) { limit = maxLimit; }
+ List candles = new List();
+ string periodString = PeriodSecondsToString(periodSeconds);
+ if (startDate == null)
+ {
+ endDate ??= CryptoUtility.UtcNow;
+ startDate = endDate - TimeSpan.FromMinutes(limit.Value * periodSeconds / 60);
+ }
+ url += $"?category={MarketCategory.ToStringLowerInvariant()}&symbol={marketSymbol}&interval={periodString}&limit={limit.Value.ToStringInvariant()}&start={((long)startDate.Value.UnixTimestampFromDateTimeMilliseconds()).ToStringInvariant()}";
+
+ var responseJson = await MakeJsonRequestAsync(url);
+ var baseVolKey = MarketCategory == MarketCategory.Inverse ? 6 : 5;
+ var quoteVolKey = MarketCategory == MarketCategory.Inverse ? 5 : 6;
+ foreach (var token in responseJson["list"])
+ {
+ candles.Add(this.ParseCandle(token, marketSymbol, periodSeconds, openKey: 1, highKey: 2, lowKey: 3, closeKey: 4, timestampKey: 0, timestampType: TimestampType.UnixMilliseconds, baseVolumeKey: baseVolKey, quoteVolumeKey: quoteVolKey));
+ }
+ return candles;
+ }
+ protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100)
+ {
+ var upperLimit = MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Inverse ? 200 :
+ MarketCategory == MarketCategory.Spot ? 50 : 25;
+ var limit = Math.Min(maxCount, upperLimit);
+ string url = $"/v5/market/orderbook?category={MarketCategory.ToStringLowerInvariant()}&symbol={marketSymbol}&limit={limit}";
+ JToken token = await MakeJsonRequestAsync(url);
+ var book = ExchangeAPIExtensions.ParseOrderBookFromJTokenArrays(token, asks: "a", bids: "b", sequence: "u");
+ book.LastUpdatedUtc = CryptoUtility.ParseTimestamp(token["ts"], TimestampType.UnixMilliseconds);
+ book.ExchangeName = Name;
+ book.MarketSymbol = token["s"].ToStringInvariant();
+ book.IsFromSnapshot = true;
+ return book;
+ }
+
+ #endregion Public
+
+ #region Private
+
+ ///
+ /// Account status (is account Unified) needed in some private end-points (e.g. OnGetAmountsAvailableToTradeAsync or GetRecentOrderAsync).
+ /// Better be set with constructor. If it's not set, this method will be used to get the account status.
+ ///
+ public async Task GetAccountInfo()
+ {
+ try
+ {
+ JObject result = await MakeJsonRequestAsync("/v5/account/info", null, await GetNoncePayloadAsync());
+ int statusId = result["unifiedMarginStatus"].ConvertInvariant();
+ IsUnifiedAccount = statusId switch
+ {
+ 1 => false,
+ 2 => MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Option,
+ 3 => MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Option || MarketCategory == MarketCategory.Spot,
+ _ => throw new ArgumentOutOfRangeException($"statusId is {statusId}"),
+ };
+ }
+ catch (APIException e)
+ {
+ if (e.Message.Contains("3400026")) // for some reason bybit returns an {code:3400026, message:'account not exist'} error if account is not unified
+ {
+ IsUnifiedAccount = false;
+ }
+ else
+ {
+ throw;
+ }
+ }
+ }
+ protected override async Task> OnGetAmountsAvailableToTradeAsync()
+ {
+ if (IsUnifiedAccount == null)
+ {
+ await GetAccountInfo();
+ }
+ var payload = await GetNoncePayloadAsync();
+ string accType = MarketCategory == MarketCategory.Inverse ? "CONTRACT" :
+ IsUnifiedAccount.Value ? "UNIFIED" :
+ MarketCategory == MarketCategory.Spot ? "SPOT" : "CONTRACT";
+ payload["accountType"] = accType;
+
+ JObject result = await MakeJsonRequestAsync("/v5/account/wallet-balance", null, payload);
+ Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ var accountBalances = result["list"].FirstOrDefault(i => i["accountType"].ToStringInvariant() == accType);
+ if (accountBalances == null) return amounts;
+ if (IsUnifiedAccount.Value)
+ {
+ // All assets that can be used as collateral, converted to USD, will be here
+ amounts.Add("USD", accountBalances["totalAvailableBalance"].ConvertInvariant());
+ }
+ string balanceKey = accType == "SPOT" ? "free" : "availableToWithdraw";
+ foreach (var coin in accountBalances["coin"])
+ {
+
+ decimal amount = coin[balanceKey].ConvertInvariant();
+ if (amount > 0m)
+ {
+ string coinName = coin["coin"].ToStringInvariant();
+ amounts[coinName] = amount;
+ }
+ }
+ return amounts;
+ }
+ protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order)
+ {
+ Dictionary payload = await GetNoncePayloadAsync();
+ payload.Add("symbol", order.MarketSymbol);
+ payload.Add("side", order.IsBuy ? "Buy" : "Sell");
+ payload.Add("orderType", order.OrderType.ToStringInvariant());
+ payload.Add("qty", order.Amount.ToStringInvariant());
+ if (!string.IsNullOrWhiteSpace(order.ClientOrderId))
+ {
+ payload.Add("orderLinkId", order.ClientOrderId);
+ }
+ else if (MarketCategory == MarketCategory.Option)
+ {
+ throw new ArgumentNullException("OrderLinkId is required for market category 'Option'");
+ }
+ if (order.Price > 0)
+ {
+ payload.Add("price", order.Price.ToStringInvariant());
+ }
+ else if (order.OrderType == OrderType.Limit)
+ {
+ throw new ArgumentNullException("Price is required for LIMIT order type.");
+ }
+ if (order.OrderType == OrderType.Stop)
+ {
+ if (order.StopPrice == 0)
+ {
+ throw new ArgumentNullException("StopPrice is required for STOP order type.");
+ }
+ if (MarketCategory == MarketCategory.Spot)
+ {
+ payload["orderFilter"] = "tpslOrder";
+ }
+ payload["triggerPrice"] = order.StopPrice.ToStringInvariant();
+ payload["orderType"] = order.Price > 0m ? "Limit" : "Market";
+ payload["triggerDirection"] = order.IsBuy ? 1 : 2;
+ }
+ order.ExtraParameters.CopyTo(payload);
+
+ JToken token = await MakeJsonRequestAsync("/v5/order/create", null, payload, "POST");
+ ExchangeOrderResult orderResult = new ExchangeOrderResult()
+ {
+ Amount = order.Amount,
+ MarketSymbol = order.MarketSymbol,
+ IsBuy = order.IsBuy,
+ Price = order.Price,
+ ClientOrderId = order.ClientOrderId,
+ OrderId = token["orderId"].ToStringInvariant()
+ };
+ return orderResult;
+ }
+ protected override async Task OnCancelOrderAsync(string orderId, string? marketSymbol = null, bool isClientOrderId = false)
+ {
+ Dictionary payload = await GetNoncePayloadAsync();
+ payload.Add("symbol", marketSymbol);
+ if (isClientOrderId)
+ {
+ payload.Add("orderLinkId", orderId);
+ }
+ else
+ {
+ payload.Add("orderId", orderId);
+ }
+ try
+ {
+ await MakeJsonRequestAsync("/v5/order/cancel", null, payload, "POST");
+ }
+ catch (APIException e)
+ {
+ // Spot STOP orders need to be cancelled in a specific way
+ if (MarketCategory == MarketCategory.Spot && (e.Message.Contains("170145") || e.Message.Contains("170213"))) // 170145 - This order type does not support cancellation, 170213 - Order does not exist.
+ {
+ payload = await GetNoncePayloadAsync();
+ payload.Add("symbol", marketSymbol);
+ if (isClientOrderId)
+ {
+ payload.Add("orderLinkId", orderId);
+ }
+ else
+ {
+ payload.Add("orderId", orderId);
+ }
+ payload.Add("orderFilter", "tpslOrder");
+ await MakeJsonRequestAsync("/v5/order/cancel", null, payload, "POST");
+ }
+ else throw;
+ }
+ }
+ protected override async Task OnGetOrderDetailsAsync(string orderId, string? marketSymbol = null, bool isClientOrderId = false)
+ {
+ // WARNING: This method can't query spot STOP orders.
+ // Need to add bool paramater to distinguish stop orders and then if order is stop add 'orderFilter' to payload. Like in GetRecentOrderAsync()
+ Dictionary payload = await GetNoncePayloadAsync();
+ if((MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Inverse) && string.IsNullOrEmpty(marketSymbol))
+ {
+ throw new ArgumentNullException("marketSymbol is null. For linear & inverse, either symbol or settleCoin is required");
+ }
+ payload.Add("symbol", marketSymbol);
+ if (isClientOrderId)
+ {
+ payload.Add("orderLinkId", orderId);
+ }
+ else
+ {
+ payload.Add("orderId", orderId);
+ }
+
+ JToken obj = await MakeJsonRequestAsync("/v5/order/history", null, payload);
+ var item = obj?["list"]?.FirstOrDefault();
+ return item == null ? null : ParseOrder(item);
+
+ }
+ public async Task GetRecentOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false, bool? isStop = null)
+ {
+ if ((MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Inverse) && string.IsNullOrEmpty(marketSymbol))
+ {
+ throw new ArgumentNullException("marketSymbol is null. For linear & inverse, either symbol or settleCoin is required");
+ }
+ if (IsUnifiedAccount == null)
+ {
+ await GetAccountInfo();
+ }
+ Dictionary payload = await GetNoncePayloadAsync();
+ payload.Add("symbol", marketSymbol);
+ int openOnlyInt = MarketCategory == MarketCategory.Spot && !IsUnifiedAccount.Value ? 0 :
+ MarketCategory == MarketCategory.Inverse || (MarketCategory == MarketCategory.Linear && !IsUnifiedAccount.Value) ? 2 : 1;
+ if (MarketCategory == MarketCategory.Spot && !IsUnifiedAccount.Value)
+ {
+ if (isStop == null)
+ {
+ throw new ArgumentNullException("isStop needed for spot not-unified account");
+ }
+ if (isStop.Value)
+ {
+ payload.Add("orderFilter", "tpslOrder");
+ }
+ }
+ payload.Add("openOnly", openOnlyInt);
+
+ if (isClientOrderId)
+ {
+ payload.Add("orderLinkId", orderId);
+ }
+ else
+ {
+ payload.Add("orderId", orderId);
+ }
+
+ JToken obj = await MakeJsonRequestAsync("/v5/order/realtime", null, payload);
+ var item = obj["list"]?.FirstOrDefault();
+ return item == null ? null : ParseOrder(item);
+ }
+ ///
+ /// Returns open, partially filled orders, also cancelled, rejected or totally filled orders by last 10 minutes for linear, inverse and spot(unified).
+ /// Spot(not unified) returns only open orders
+ ///
+ protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null)
+ {
+ Dictionary payload = await GetNoncePayloadAsync();
+ payload.Add("symbol", marketSymbol);
+ JToken obj = await MakeJsonRequestAsync("/v5/order/realtime", null, payload);
+ List orders = new List();
+ if (obj?["list"] is JArray jArray)
+ {
+ foreach (var item in jArray)
+ {
+ orders.Add(ParseOrder(item));
+ }
+ }
+ return orders;
+ }
+
+ #endregion Private
+
+ #region WebSockets Public
+
+ protected override async Task OnGetCandlesWebSocketAsync(Func callbackAsync, int periodSeconds, params string[] marketSymbols)
+ {
+ return await ConnectPublicWebSocketAsync(url: null,
+ messageCallback: async (_socket, msg) =>
+ {
+ JToken token = JToken.Parse(msg.ToStringFromUTF8());
+ string topic = token["topic"]?.ToStringInvariant();
+ if (string.IsNullOrWhiteSpace(topic))
+ {
+ string op = token["op"]?.ToStringInvariant();
+ if (op == "subscribe")
+ {
+ if (token["success"].ConvertInvariant())
+ {
+ Logger.Info($"Subscribed to candles websocket on {Name}");
+ }
+ else
+ {
+ Logger.Error($"Error subscribing to candles websocket on {Name}: {token["ret_msg"].ToStringInvariant()}");
+ }
+ }
+ return;
+ }
+ var strArray = topic.Split('.');
+ string symbol = strArray[strArray.Length - 1];
+ int periodSecondsInResponse = StringToPeriodSeconds(strArray[strArray.Length - 2]);
+
+ foreach (var item in token["data"] as JArray)
+ {
+ MarketCandle candle = this.ParseCandle(item, symbol, periodSecondsInResponse, openKey: "open", highKey: "high", lowKey: "low", closeKey: "close",
+ timestampKey: "start", timestampType: TimestampType.UnixMilliseconds,
+ baseVolumeKey: MarketCategory == MarketCategory.Inverse ? "turnover" : "volume",
+ quoteVolumeKey: MarketCategory == MarketCategory.Inverse ? "volume" : "turnover");
+ candle.IsClosed = item["confirm"].ConvertInvariant();
+ await callbackAsync(candle);
+ }
+ },
+ connectCallback: async (_socket) =>
+ {
+ string interval = PeriodSecondsToString(periodSeconds);
+ var subscribeRequest = new
+ {
+ op = "subscribe",
+ args = marketSymbols.Select(s => $"kline.{interval}.{s}").ToArray()
+ };
+ await _socket.SendMessageAsync(subscribeRequest);
+ },
+ disconnectCallback: async (_socket) =>
+ {
+ Logger.Info($"Websocket for candles on {Name} disconnected");
+ });
+ }
+
+ protected override async Task OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 20, params string[] marketSymbols)
+ {
+ /*
+ ABOUT DEPTH:
+ Linear & inverse:
+ Level 1 data, push frequency: 10ms
+ Level 50 data, push frequency: 20ms
+ Level 200 data, push frequency: 100ms
+ Level 500 data, push frequency: 100ms
+
+ Spot:
+ Level 1 data, push frequency: 10ms
+ Level 50 data, push frequency: 20ms
+
+ Option:
+ Level 25 data, push frequency: 20ms
+ Level 100 data, push frequency: 100ms
+ */
+ int depth;
+ if (MarketCategory == MarketCategory.Linear || MarketCategory == MarketCategory.Inverse)
+ {
+ depth = (maxCount == 1 || maxCount == 200 || maxCount == 500) ? maxCount : 50; // Depth 50 by default
+ }
+ else if (MarketCategory == MarketCategory.Spot)
+ {
+ depth = maxCount == 1 ? maxCount : 50; // Depth 50 by default
+ }
+ else //Option
+ {
+ depth = maxCount == 100 ? maxCount : 25; //Depth 25 by default
+ }
+ return await ConnectPublicWebSocketAsync(url: null,
+ messageCallback: (_socket, msg) =>
+ {
+ JToken token = JToken.Parse(msg.ToStringFromUTF8());
+ string topic = token["topic"]?.ToStringInvariant();
+ if (string.IsNullOrWhiteSpace(topic))
+ {
+ string op = token["op"]?.ToStringInvariant();
+ if (op == "subscribe")
+ {
+ if (token["success"].ConvertInvariant())
+ {
+ Logger.Info($"Subscribed to orderbook websocket on {Name}");
+ }
+ else
+ {
+ Logger.Error($"Error subscribing to orderbook websocket on {Name}: {token["ret_msg"].ToStringInvariant()}");
+ }
+ }
+ return Task.CompletedTask;
+ }
+ var book = ExchangeAPIExtensions.ParseOrderBookFromJTokenArrays(token["data"], asks: "a", bids: "b", sequence: "u");
+ book.LastUpdatedUtc = CryptoUtility.ParseTimestamp(token["ts"], TimestampType.UnixMilliseconds);
+ book.ExchangeName = Name;
+ book.MarketSymbol = token["data"]["s"].ToStringInvariant();
+ book.IsFromSnapshot = token["type"].ToStringInvariant() == "snapshot";
+ callback(book);
+ return Task.CompletedTask;
+ },
+ connectCallback: async (_socket) =>
+ {
+ var subscribeRequest = new
+ {
+ op = "subscribe",
+ args = marketSymbols.Select(s => $"orderbook.{depth}.{s}").ToArray()
+ };
+ await _socket.SendMessageAsync(subscribeRequest);
+ },
+ disconnectCallback: async (_socket) =>
+ {
+ Logger.Info($"Websocket for orderbook on {Name} disconnected");
+ });
+ }
+
+ #endregion Websockets Public
+
+ #region Websockets Private
+
+ protected override async Task OnUserDataWebSocketAsync(Action