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 callback) + { + return await ConnectPrivateWebSocketAsync(url: null, + messageCallback: (_socket, msg) => + { + JToken token = JToken.Parse(msg.ToStringFromUTF8()); + + var data = token["data"]; + if (data == null) + { + string op = token["op"]?.ToStringInvariant(); + if (op == "auth") + { + if (token["success"].ConvertInvariant()) + { + Logger.Info($"Authenticated to user data websocket on {Name}"); + } + else + { + Logger.Error($"Error authenticating to user data websocket on {Name}: {token["ret_msg"].ToStringInvariant()}"); + } + } + else if (op == "subscribe") + { + if (token["success"].ConvertInvariant()) + { + Logger.Info($"Subscribed to user data websocket on {Name}"); + } + else + { + Logger.Error($"Error subscribing to user data websocket on {Name}: {token["ret_msg"].ToStringInvariant()}"); + } + } + } + else + { + var topic = token["topic"]?.ToStringInvariant(); + switch (topic) + { + case "order": + foreach (var jObject in data) + { + callback(ParseOrder(jObject)); + } + break; + // Just orders for now, other topics can be added + } + } + return Task.CompletedTask; + }, + connectCallback: async (_socket) => + { + long expires = (long)DateTime.Now.UnixTimestampFromDateTimeMilliseconds() + 10000; //10 seconds for auth request + string stringToSign = $"GET/realtime{expires}"; + string signature = CryptoUtility.SHA256Sign(stringToSign, CryptoUtility.ToUnsecureString(PrivateApiKey)); + var authRequest = new + { + op = "auth", + args = new string[] { CryptoUtility.ToUnsecureString(PublicApiKey), expires.ToStringInvariant(), signature } + }; + await _socket.SendMessageAsync(authRequest); + + var subscribeRequest = new + { + op = "subscribe", + args = new string[] { "order" } + }; + await _socket.SendMessageAsync(subscribeRequest); + + }, + disconnectCallback: async (_socket) => + { + Logger.Info($"Socket for user data on {Name} disconnected"); + }); + } + + #endregion Websockets Private + + #region Helpers + + private ExchangeOrderResult ParseOrder(JToken jToken) + { + string marketSymbol = jToken["symbol"].ToStringInvariant(); + decimal executedValue = jToken["cumExecValue"].ConvertInvariant(); + decimal amountFilled = jToken["cumExecQty"].ConvertInvariant(); + decimal averagePrice = (executedValue == 0 || amountFilled == 0) ? 0m : executedValue / amountFilled; + decimal triggerPrice = jToken["triggerPrice"]?.ConvertInvariant() ?? 0; + decimal price = jToken["price"].ConvertInvariant(); + ExchangeOrderResult order = new ExchangeOrderResult + { + MarketSymbol = marketSymbol, + Amount = jToken["qty"].ConvertInvariant(), + AmountFilled = amountFilled, + Price = price == 0 ? triggerPrice : price, + Fees = jToken["cumExecFee"].ConvertInvariant(), + FeesCurrency = marketSymbol.EndsWith("USDT") && MarketCategory == MarketCategory.Linear ? "USDT" : null, + AveragePrice = averagePrice, + IsBuy = jToken["side"].ToStringInvariant() == "Buy", + OrderDate = jToken["createdTime"] == null ? default : CryptoUtility.ParseTimestamp(jToken["createdTime"], TimestampType.UnixMilliseconds), + OrderId = jToken["orderId"]?.ToStringInvariant(), + ClientOrderId = jToken["orderLinkId"]?.ToStringInvariant(), + Result = StringToOrderStatus(jToken["orderStatus"].ToStringInvariant()) + }; + if (order.Result == ExchangeAPIOrderResult.Filled || order.Result == ExchangeAPIOrderResult.Rejected || order.Result == ExchangeAPIOrderResult.Canceled) + { + order.CompletedDate = jToken["updatedTime"] == null ? (DateTime?)null : CryptoUtility.ParseTimestamp(jToken["updatedTime"], TimestampType.UnixMilliseconds); + if(order.Result == ExchangeAPIOrderResult.Filled) + { + order.TradeDate = order.CompletedDate; + } + } + return order; + } + public override string PeriodSecondsToString(int seconds) + { + switch (seconds) + { + // 1,3,5,15,30,60,120,240,360,720,D,M,W + case 60: + case 3 * 60: + case 5 * 60: + case 15 * 60: + case 30 * 60: + case 60 * 60: + case 120 * 60: + case 240 * 60: + case 360 * 60: + case 720 * 60: + return (seconds / 60).ToStringInvariant(); + case 24 * 60 * 60: + return "D"; + case 7 * 24 * 60 * 60: + return "W"; + case 30 * 24 * 60 * 60: + return "M"; + default: + throw new ArgumentOutOfRangeException("seconds"); + } + } + private int StringToPeriodSeconds(string str) + { + switch (str) + { + case "M": + return 30 * 24 * 60 * 60; + case "W": + return 7 * 24 * 60 * 60; + case "D": + return 24 * 60 * 60; + default: + return int.Parse(str) * 60; + } + } + protected ExchangeAPIOrderResult StringToOrderStatus(string orderStatus) + { + switch (orderStatus) + { + case "Created": + case "New": + case "Active": + case "Untriggered": + case "Triggered": + return ExchangeAPIOrderResult.Open; + case "PartiallyFilled": + return ExchangeAPIOrderResult.FilledPartially; + case "Filled": + return ExchangeAPIOrderResult.Filled; + case "Deactivated": + case "Cancelled": + case "PartiallyFilledCanceled": + return ExchangeAPIOrderResult.Canceled; + case "PendingCancel": + return ExchangeAPIOrderResult.PendingCancel; + case "Rejected": + return ExchangeAPIOrderResult.Rejected; + default: + return ExchangeAPIOrderResult.Unknown; + } + } + + #endregion Helpers + } +} diff --git a/src/ExchangeSharp/API/Exchanges/Bybit/Models/Enums.cs b/src/ExchangeSharp/API/Exchanges/Bybit/Models/Enums.cs new file mode 100644 index 00000000..776315d9 --- /dev/null +++ b/src/ExchangeSharp/API/Exchanges/Bybit/Models/Enums.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace ExchangeSharp.Bybit +{ + public enum MarketCategory + { + Linear, + Inverse, + Option, + Spot + } +} diff --git a/src/ExchangeSharp/Model/ExchangeOrderBook.cs b/src/ExchangeSharp/Model/ExchangeOrderBook.cs index 8ce612ae..c7745233 100644 --- a/src/ExchangeSharp/Model/ExchangeOrderBook.cs +++ b/src/ExchangeSharp/Model/ExchangeOrderBook.cs @@ -67,11 +67,16 @@ public ExchangeOrderPrice(BinaryReader reader) /// public sealed class ExchangeOrderBook { - /// - /// The sequence id. This increments as updates come through. Not all exchanges will populate this. - /// This property is not serialized using the ToBinary and FromBinary methods. - /// - public long SequenceId { get; set; } + /// + /// Needed to distinguish between fool book and deltas + /// + public bool IsFromSnapshot { get; set; } + public string ExchangeName { get; set; } + /// + /// The sequence id. This increments as updates come through. Not all exchanges will populate this. + /// This property is not serialized using the ToBinary and FromBinary methods. + /// + public long SequenceId { get; set; } /// /// The market symbol. diff --git a/src/ExchangeSharp/Model/MarketCandle.cs b/src/ExchangeSharp/Model/MarketCandle.cs index 655af7a5..c6135423 100644 --- a/src/ExchangeSharp/Model/MarketCandle.cs +++ b/src/ExchangeSharp/Model/MarketCandle.cs @@ -1,4 +1,4 @@ -/* +/* MIT LICENSE Copyright 2017 Digital Ruby, LLC - http://www.digitalruby.com @@ -83,6 +83,7 @@ public class MarketCandle /// public int Count { get; set; } + public bool IsClosed { get; set; } /// /// ToString ///