diff --git a/README.md b/README.md index 780bb353..ff8ad0ef 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The following cryptocurrency exchanges are supported: | Livecoin | x | x | | | NDAX | x | x | T R | | OKCoin | x | x | R B | -| OKEx | x | x | R B | +| OKEx | x | x | R B O | | Poloniex | x | x | T R B | | YoBit | x | x | | | ZB.com | wip | | R | diff --git a/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs b/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs index 208b18c3..59b41ed8 100644 --- a/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs @@ -16,7 +16,9 @@ The above copyright notice and this permission notice shall be included in all c using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; +using System.Xml; using ExchangeSharp.OKGroup; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -28,7 +30,7 @@ public sealed partial class ExchangeOKExAPI : OKGroupCommon public override string BaseUrl { get; set; } = "https://www.okex.com/api/v1"; public override string BaseUrlV2 { get; set; } = "https://www.okex.com/v2/spot"; public override string BaseUrlV3 { get; set; } = "https://www.okex.com/api"; - public override string BaseUrlWebSocket { get; set; } = "wss://real.okex.com:8443/ws/v3"; + public override string BaseUrlWebSocket { get; set; } = "wss://ws.okex.com:8443/ws/v5"; public string BaseUrlV5 { get; set; } = "https://www.okex.com/api/v5"; protected override bool IsFuturesAndSwapEnabled { get; } = true; @@ -317,6 +319,7 @@ protected override async Task OnPlaceOrderAsync(ExchangeOrd { payload["clOrdId"] = order.ClientOrderId; } + payload["side"] = order.IsBuy ? "buy" : "sell"; payload["posSide"] = "net"; payload["ordType"] = order.OrderType switch @@ -330,7 +333,8 @@ protected override async Task OnPlaceOrderAsync(ExchangeOrd payload["sz"] = order.Amount.ToStringInvariant(); if (order.OrderType != OrderType.Market) { - if (!order.Price.HasValue) throw new ArgumentNullException(nameof(order.Price), "Okex place order request requires price"); + if (!order.Price.HasValue) + throw new ArgumentNullException(nameof(order.Price), "Okex place order request requires price"); payload["px"] = order.Price.ToStringInvariant(); } @@ -379,30 +383,269 @@ protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dicti } } + protected override async Task OnGetTickersWebSocketAsync( + Action>> callback, + params string[] symbols) + { + return await ConnectWebSocketOkexAsync( + async (socket) => { await AddMarketSymbolsToChannel(socket, "tickers", symbols); }, + async (socket, symbol, sArray, token) => + { + var tickers = new List> + { + new KeyValuePair(symbol, await ParseTickerV5Async(token, symbol)) + }; + callback(tickers); + }); + } + + protected override async Task OnGetTradesWebSocketAsync( + Func, Task> callback, params string[] marketSymbols) + { + return await ConnectWebSocketOkexAsync( + async (_socket) => { await AddMarketSymbolsToChannel(_socket, "trades", marketSymbols); }, + async (_socket, symbol, sArray, token) => + { + var trade = token.ParseTrade("sz", "px", "side", "ts", TimestampType.UnixMilliseconds, "tradeId"); + await callback(new KeyValuePair(symbol, trade)); + }); + } + + protected override async Task OnGetDeltaOrderBookWebSocketAsync(Action callback, + int maxCount = 20, params string[] marketSymbols) + { + return await ConnectWebSocketOkexAsync( + async (_socket) => + { + marketSymbols = await AddMarketSymbolsToChannel(_socket, "books-l2-tbt", marketSymbols); + }, (_socket, symbol, sArray, token) => + { + ExchangeOrderBook book = token.ParseOrderBookFromJTokenArrays(maxCount: maxCount); + book.MarketSymbol = symbol; + callback(book); + return Task.CompletedTask; + }); + } + + protected override async Task OnGetOrderDetailsWebSocketAsync(Action callback) + { + return await ConnectPrivateWebSocketOkexAsync(async (_socket) => + { + await WebsocketLogin(_socket); + await SubscribeForOrderChannel(_socket, "orders"); + }, (_socket, symbol, sArray, token) => + { + callback(ParseOrder(token)); + return Task.CompletedTask; + }); + } + + protected override Task ConnectWebSocketOkexAsync(Func connected, + Func callback, int symbolArrayIndex = 3) + { + Timer pingTimer = null; + return ConnectPublicWebSocketAsync(url: "/public", messageCallback: async (_socket, msg) => + { + var msgString = msg.ToStringFromUTF8(); + if (msgString == "pong") + { + // received reply to our ping + return; + } + + JToken token = JToken.Parse(msgString); + var eventProperty = token["event"]?.ToStringInvariant(); + if (eventProperty != null) + { + switch (eventProperty) + { + case "error": + Logger.Info("Websocket unable to connect: " + token["msg"]?.ToStringInvariant()); + return; + case "subscribe" when token["arg"]["channel"] != null: + { + // subscription successful + pingTimer ??= new Timer(callback: async s => await _socket.SendMessageAsync("ping"), + null, 0, 15000); + return; + } + default: + return; + } + } + + var marketSymbol = string.Empty; + if (token["arg"] != null) + { + marketSymbol = token["arg"]["instId"].ToStringInvariant(); + } + + if (token["data"] != null) + { + var data = token["data"]; + foreach (var t in data) + { + await callback(_socket, marketSymbol, null, t); + } + } + }, async (_socket) => await connected(_socket) + , s => + { + pingTimer?.Dispose(); + pingTimer = null; + return Task.CompletedTask; + }); + } + + protected override Task ConnectPrivateWebSocketOkexAsync(Func connected, + Func callback, int symbolArrayIndex = 3) + { + Timer pingTimer = null; + return ConnectPublicWebSocketAsync(url: "/private", messageCallback: async (_socket, msg) => + { + var msgString = msg.ToStringFromUTF8(); + Logger.Debug(msgString); + if (msgString == "pong") + { + // received reply to our ping + return; + } + + JToken token = JToken.Parse(msgString); + var eventProperty = token["event"]?.ToStringInvariant(); + if (eventProperty != null) + { + switch (eventProperty) + { + case "error": + Logger.Info("Websocket unable to connect: " + token["msg"]?.ToStringInvariant()); + return; + case "subscribe" when token["arg"]["channel"] != null: + { + // subscription successful + pingTimer ??= new Timer(callback: async s => await _socket.SendMessageAsync("ping"), + null, 0, 15000); + return; + } + default: + return; + } + } + + var marketSymbol = string.Empty; + if (token["arg"] != null) + { + marketSymbol = token["arg"]["instId"].ToStringInvariant(); + } + + if (token["data"] != null) + { + var data = token["data"]; + foreach (var t in data) + { + await callback(_socket, marketSymbol, null, t); + } + } + }, async (_socket) => await connected(_socket) + , s => + { + pingTimer?.Dispose(); + pingTimer = null; + return Task.CompletedTask; + }); + } + + protected override async Task AddMarketSymbolsToChannel(IWebSocket socket, string channelFormat, + string[] marketSymbols) + { + if (marketSymbols.Length == 0) + { + marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + } + + await SendMessageAsync(marketSymbols); + + async Task SendMessageAsync(IEnumerable symbolsToSend) + { + var args = symbolsToSend + .Select(s => new { channel = channelFormat, instId = s }) + .ToArray(); + await socket.SendMessageAsync(new { op = "subscribe", args }); + } + + return marketSymbols; + } + + private async Task WebsocketLogin(IWebSocket socket) + { + var timestamp = (DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalSeconds; + var auth = new + { + apiKey = PublicApiKey?.ToUnsecureString(), + passphrase = Passphrase?.ToUnsecureString(), + timestamp, + sign = CryptoUtility.SHA256SignBase64($"{timestamp}GET/users/self/verify", + PrivateApiKey?.ToUnsecureBytesUTF8()) + }; + var args = new List { auth }; + var request = new { op = "login", args }; + await socket.SendMessageAsync(request); + } + + private async Task SubscribeForOrderChannel(IWebSocket socket, string channelFormat) + { + var marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + await SendMessageAsync(marketSymbols); + + async Task SendMessageAsync(IEnumerable symbolsToSend) + { + var args = symbolsToSend + .Select(s => new + { + channel = channelFormat, instId = s, uly = GetUly(s), + instType = GetInstrumentType(s).ToUpperInvariant() + }) + .ToArray(); + await socket.SendMessageAsync(new { op = "subscribe", args }); + } + } + + private static string GetUly(string marketSymbol) + { + var symbolSplit = marketSymbol.Split('-'); + return symbolSplit.Length == 3 ? $"{symbolSplit[0]}-{symbolSplit[1]}" : marketSymbol; + } + private async Task GetBalance() { return await MakeJsonRequestAsync("/account/balance", BaseUrlV5, await GetNoncePayloadAsync()); } - private IEnumerable ParseOrders(JToken token) - => token.Select(x => - new ExchangeOrderResult() + private static ExchangeOrderResult ParseOrder(JToken token) => + new ExchangeOrderResult() + { + OrderId = token["ordId"].Value(), + OrderDate = DateTimeOffset.FromUnixTimeMilliseconds(token["cTime"].Value()).DateTime, + Result = token["state"].Value() switch { - OrderId = x["ordId"].Value(), - OrderDate = DateTimeOffset.FromUnixTimeMilliseconds(x["cTime"].Value()).DateTime, - Result = x["state"].Value() == "live" - ? ExchangeAPIOrderResult.Open - : ExchangeAPIOrderResult.FilledPartially, - IsBuy = x["side"].Value() == "buy", - IsAmountFilledReversed = false, - Amount = x["sz"].Value(), - AmountFilled = x["accFillSz"].Value(), - AveragePrice = x["avgPx"].Value() == string.Empty ? default : x["avgPx"].Value(), - Price = x["px"].Value(), - ClientOrderId = x["clOrdId"].Value(), - FeesCurrency = x["feeCcy"].Value(), - MarketSymbol = x["instId"].Value() - }); + "canceled" => ExchangeAPIOrderResult.Canceled, + "live" => ExchangeAPIOrderResult.Open, + "partially_filled" => ExchangeAPIOrderResult.FilledPartially, + "filled" => ExchangeAPIOrderResult.Filled, + _ => ExchangeAPIOrderResult.Unknown + }, + IsBuy = token["side"].Value() == "buy", + IsAmountFilledReversed = false, + Amount = token["sz"].Value(), + AmountFilled = token["accFillSz"].Value(), + AveragePrice = token["avgPx"].Value() == string.Empty ? default : token["avgPx"].Value(), + Price = token["px"].Value(), + ClientOrderId = token["clOrdId"].Value(), + FeesCurrency = token["feeCcy"].Value(), + MarketSymbol = token["instId"].Value() + }; + + private static IEnumerable ParseOrders(JToken token) => token.Select(ParseOrder); private async Task ParseTickerV5Async(JToken t, string symbol) { diff --git a/src/ExchangeSharp/API/Exchanges/OKGroup/OKGroupCommon.cs b/src/ExchangeSharp/API/Exchanges/OKGroup/OKGroupCommon.cs index 7911b399..73e1c009 100644 --- a/src/ExchangeSharp/API/Exchanges/OKGroup/OKGroupCommon.cs +++ b/src/ExchangeSharp/API/Exchanges/OKGroup/OKGroupCommon.cs @@ -503,6 +503,121 @@ protected override async Task> OnGetOpenOrderDe return orders; } + #endregion + + #region WebSocket Functions + + protected virtual Task ConnectWebSocketOkexAsync(Func connected, Func callback, int symbolArrayIndex = 3) + { + Timer pingTimer = null; + return ConnectPublicWebSocketAsync(url: string.Empty, messageCallback: async (_socket, msg) => + { + // https://github.com/okcoin-okex/API-docs-OKEx.com/blob/master/README-en.md + // All the messages returning from WebSocket API will be optimized by Deflate compression + var msgString = msg.ToStringFromUTF8Deflate(); + if (msgString == "pong") + { // received reply to our ping + return; + } + JToken token = JToken.Parse(msgString); + var eventProperty = token["event"]?.ToStringInvariant(); + if (eventProperty != null) + { + if (eventProperty == "error") + { + Logger.Info("Websocket unable to connect: " + token["message"]?.ToStringInvariant()); + return; + } + else if (eventProperty == "subscribe" && token["channel"] != null) + { // subscription successful + if (pingTimer == null) + { + pingTimer = new Timer(callback: async s => await _socket.SendMessageAsync("ping"), + state: null, dueTime: 0, period: 15000); // send a ping every 15 seconds + } + return; + } + else return; + } + else if (token["table"] != null) + { + var data = token["data"]; + foreach (var dataRow in data) + { + var marketSymbol = dataRow["instrument_id"].ToStringInvariant(); + await callback(_socket, marketSymbol, null, dataRow); + } + } + }, connectCallback: async (_socket) => await connected(_socket) + , disconnectCallback: s => + { + pingTimer.Dispose(); + pingTimer = null; + return Task.CompletedTask; + }); + } + + protected virtual Task ConnectPrivateWebSocketOkexAsync(Func connected, Func callback, int symbolArrayIndex = 3) + { + return ConnectWebSocketOkexAsync(async (_socket) => + { + await _socket.SendMessageAsync(GetAuthForWebSocket()); + }, async (_socket, symbol, sArray, token) => + { + if (symbol == "login") + { + await connected(_socket); + } + else + { + await callback(_socket, symbol, sArray, token); + } + }, 0); + } + + protected virtual async Task AddMarketSymbolsToChannel(IWebSocket socket, string channelFormat, string[] marketSymbols) + { + if (marketSymbols == null || marketSymbols.Length == 0) + { + marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + } + var spotSymbols = marketSymbols.Where(ms => ms.Split('-').Length == 2); + var futureSymbols = marketSymbols.Where( + ms => ms.Split('-').Length == 3 && int.TryParse(ms.Split('-')[2], out int i)); + var swapSymbols = marketSymbols.Where( + ms => ms.Split('-').Length == 3 && ms.Split('-')[2] == "SWAP"); + + await sendMessageAsync("spot", spotSymbols); + await sendMessageAsync("futures", futureSymbols); + await sendMessageAsync("swap", swapSymbols); + + async Task sendMessageAsync(string category, IEnumerable symbolsToSend) + { + var channels = symbolsToSend + .Select(marketSymbol => string.Format($"{category}{channelFormat}", NormalizeMarketSymbol(marketSymbol))) + .ToArray(); + await socket.SendMessageAsync(new { op = "subscribe", args = channels }); + } + return marketSymbols; + } + + protected string GetInstrumentType(string marketSymbol) + { + string type; + if (marketSymbol.Split('-').Length == 3 && marketSymbol.Split('-')[2] == "SWAP") + { + type = "swap"; + } + else if (marketSymbol.Split('-').Length == 3 && int.TryParse(marketSymbol.Split('-')[2], out _)) + { + type = "futures"; + } + else + { + type = "spot"; + } + return type; + } #endregion #region Private Functions @@ -664,119 +779,6 @@ private ExchangeOrderResult ParseOrder(JToken token) return result; } - - private Task ConnectWebSocketOkexAsync(Func connected, Func callback, int symbolArrayIndex = 3) - { - Timer pingTimer = null; - return ConnectPublicWebSocketAsync(url: string.Empty, messageCallback: async (_socket, msg) => - { - // https://github.com/okcoin-okex/API-docs-OKEx.com/blob/master/README-en.md - // All the messages returning from WebSocket API will be optimized by Deflate compression - var msgString = msg.ToStringFromUTF8Deflate(); - if (msgString == "pong") - { // received reply to our ping - return; - } - JToken token = JToken.Parse(msgString); - var eventProperty = token["event"]?.ToStringInvariant(); - if (eventProperty != null) - { - if (eventProperty == "error") - { - Logger.Info("Websocket unable to connect: " + token["message"]?.ToStringInvariant()); - return; - } - else if (eventProperty == "subscribe" && token["channel"] != null) - { // subscription successful - if (pingTimer == null) - { - pingTimer = new Timer(callback: async s => await _socket.SendMessageAsync("ping"), - state: null, dueTime: 0, period: 15000); // send a ping every 15 seconds - } - return; - } - else return; - } - else if (token["table"] != null) - { - var data = token["data"]; - foreach (var dataRow in data) - { - var marketSymbol = dataRow["instrument_id"].ToStringInvariant(); - await callback(_socket, marketSymbol, null, dataRow); - } - } - }, connectCallback: async (_socket) => await connected(_socket) - , disconnectCallback: s => - { - pingTimer.Dispose(); - pingTimer = null; - return Task.CompletedTask; - }); - } - - private Task ConnectPrivateWebSocketOkexAsync(Func connected, Func callback, int symbolArrayIndex = 3) - { - return ConnectWebSocketOkexAsync(async (_socket) => - { - await _socket.SendMessageAsync(GetAuthForWebSocket()); - }, async (_socket, symbol, sArray, token) => - { - if (symbol == "login") - { - await connected(_socket); - } - else - { - await callback(_socket, symbol, sArray, token); - } - }, 0); - } - - private async Task AddMarketSymbolsToChannel(IWebSocket socket, string channelFormat, string[] marketSymbols) - { - if (marketSymbols == null || marketSymbols.Length == 0) - { - marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - } - var spotSymbols = marketSymbols.Where(ms => ms.Split('-').Length == 2); - var futureSymbols = marketSymbols.Where( - ms => ms.Split('-').Length == 3 && int.TryParse(ms.Split('-')[2], out int i)); - var swapSymbols = marketSymbols.Where( - ms => ms.Split('-').Length == 3 && ms.Split('-')[2] == "SWAP"); - - await sendMessageAsync("spot", spotSymbols); - await sendMessageAsync("futures", futureSymbols); - await sendMessageAsync("swap", swapSymbols); - - async Task sendMessageAsync(string category, IEnumerable symbolsToSend) - { - var channels = symbolsToSend - .Select(marketSymbol => string.Format($"{category}{channelFormat}", NormalizeMarketSymbol(marketSymbol))) - .ToArray(); - await socket.SendMessageAsync(new { op = "subscribe", args = channels }); - } - return marketSymbols; - } - - private string GetInstrumentType(string marketSymbol) - { - string type; - if (marketSymbol.Split('-').Length == 3 && marketSymbol.Split('-')[2] == "SWAP") - { - type = "swap"; - } - else if (marketSymbol.Split('-').Length == 3 && int.TryParse(marketSymbol.Split('-')[2], out _)) - { - type = "futures"; - } - else - { - type = "spot"; - } - return type; - } - - #endregion + #endregion } }