diff --git a/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs b/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs index 14dd6c2a..208b18c3 100644 --- a/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/OKGroup/ExchangeOKExAPI.cs @@ -10,11 +10,15 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +#nullable enable using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using ExchangeSharp.OKGroup; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace ExchangeSharp @@ -25,7 +29,7 @@ public sealed partial class ExchangeOKExAPI : OKGroupCommon 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 string BaseUrlV5 { get; set; } = "https://okex.com/api/v5"; + public string BaseUrlV5 { get; set; } = "https://www.okex.com/api/v5"; protected override bool IsFuturesAndSwapEnabled { get; } = true; public override string PeriodSecondsToString(int seconds) @@ -68,33 +72,39 @@ protected internal override async Task> OnGetMarketS } */ var markets = new List(); - parseMarketSymbolTokens(await MakeJsonRequestAsync( + ParseMarketSymbolTokens(await MakeJsonRequestAsync( "/public/instruments?instType=SPOT", BaseUrlV5)); if (!IsFuturesAndSwapEnabled) return markets; - parseMarketSymbolTokens(await MakeJsonRequestAsync( + ParseMarketSymbolTokens(await MakeJsonRequestAsync( "/public/instruments?instType=FUTURES", BaseUrlV5)); - parseMarketSymbolTokens(await MakeJsonRequestAsync( + ParseMarketSymbolTokens(await MakeJsonRequestAsync( "/public/instruments?instType=SWAP", BaseUrlV5)); return markets; - void parseMarketSymbolTokens(JToken allMarketSymbolTokens) + void ParseMarketSymbolTokens(JToken allMarketSymbolTokens) { markets.AddRange(from marketSymbolToken in allMarketSymbolTokens - let isSpot = marketSymbolToken["instType"].Value() == "SPOT" - let baseCurrency = isSpot ? marketSymbolToken["baseCcy"].Value() : marketSymbolToken["settleCcy"].Value() - let quoteCurrency = isSpot ? marketSymbolToken["quoteCcy"].Value() : marketSymbolToken["ctValCcy"].Value() - select new ExchangeMarket - { - MarketSymbol = marketSymbolToken["instId"].Value(), - IsActive = marketSymbolToken["state"].Value() == "live", - QuoteCurrency = quoteCurrency, - BaseCurrency = baseCurrency, - PriceStepSize = marketSymbolToken["tickSz"].ConvertInvariant(), - MinPrice = marketSymbolToken["tickSz"].ConvertInvariant(), // assuming that this is also the min price since it isn't provided explicitly by the exchange - MinTradeSize = marketSymbolToken["minSz"].ConvertInvariant(), - QuantityStepSize = marketSymbolToken["lotSz"].ConvertInvariant() - }); + let isSpot = marketSymbolToken["instType"].Value() == "SPOT" + let baseCurrency = isSpot + ? marketSymbolToken["baseCcy"].Value() + : marketSymbolToken["settleCcy"].Value() + let quoteCurrency = isSpot + ? marketSymbolToken["quoteCcy"].Value() + : marketSymbolToken["ctValCcy"].Value() + select new ExchangeMarket + { + MarketSymbol = marketSymbolToken["instId"].Value(), + IsActive = marketSymbolToken["state"].Value() == "live", + QuoteCurrency = quoteCurrency, + BaseCurrency = baseCurrency, + PriceStepSize = marketSymbolToken["tickSz"].ConvertInvariant(), + MinPrice = marketSymbolToken["tickSz"] + .ConvertInvariant< + decimal>(), // assuming that this is also the min price since it isn't provided explicitly by the exchange + MinTradeSize = marketSymbolToken["minSz"].ConvertInvariant(), + QuantityStepSize = marketSymbolToken["lotSz"].ConvertInvariant() + }); } } @@ -108,14 +118,14 @@ protected override async Task OnGetTickerAsync(string marketSymb protected override async Task>> OnGetTickersAsync() { var tickers = new List>(); - await parseData(await MakeJsonRequestAsync("/market/tickers?instType=SPOT", BaseUrlV5)); + await ParseData(await MakeJsonRequestAsync("/market/tickers?instType=SPOT", BaseUrlV5)); if (!IsFuturesAndSwapEnabled) return tickers; - await parseData(await MakeJsonRequestAsync("/market/tickers?instType=FUTURES", BaseUrlV5)); - await parseData(await MakeJsonRequestAsync("/market/tickers?instType=SWAP", BaseUrlV5)); + await ParseData(await MakeJsonRequestAsync("/market/tickers?instType=FUTURES", BaseUrlV5)); + await ParseData(await MakeJsonRequestAsync("/market/tickers?instType=SWAP", BaseUrlV5)); return tickers; - async Task parseData(JToken tickerResponse) + async Task ParseData(JToken tickerResponse) { /*{ "code":"0", @@ -167,11 +177,13 @@ protected override async Task> OnGetRecentTradesAsync protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) { - var token = await MakeJsonRequestAsync($"/market/books?instId={marketSymbol}&sz={maxCount}", BaseUrlV5); + var token = await MakeJsonRequestAsync($"/market/books?instId={marketSymbol}&sz={maxCount}", + BaseUrlV5); return token[0].ParseOrderBookFromJTokenArrays(maxCount: maxCount); } - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) + protected override async Task> OnGetCandlesAsync(string marketSymbol, + int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) { /* { @@ -203,10 +215,195 @@ protected override async Task> OnGetCandlesAsync(strin url += $"&bar={periodString}"; var obj = await MakeJsonRequestAsync(url, BaseUrlV5); foreach (JArray token in obj) - candles.Add(this.ParseCandle(token, marketSymbol, periodSeconds, 1, 2, 3, 4, 0, TimestampType.UnixMilliseconds, 5, 6)); + candles.Add(this.ParseCandle(token, marketSymbol, periodSeconds, 1, 2, 3, 4, 0, + TimestampType.UnixMilliseconds, 5, 6)); return candles; } + protected override async Task> OnGetAmountsAsync() + { + var token = await GetBalance(); + return token[0]["details"] + .Select(x => new { Currency = x["ccy"].Value(), TotalBalance = x["cashBal"].Value() }) + .ToDictionary(k => k.Currency, v => v.TotalBalance); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + var token = await GetBalance(); + return token[0]["details"] + .Select(x => new + { Currency = x["ccy"].Value(), AvailableBalance = x["availBal"].Value() }) + .ToDictionary(k => k.Currency, v => v.AvailableBalance); + } + + protected override async Task> OnGetMarginAmountsAvailableToTradeAsync( + bool includeZeroBalances) + { + var token = await GetBalance(); + var availableEquity = token[0]["details"] + .Select(x => new + { + Currency = x["ccy"].Value(), + AvailableEquity = x["availEq"].Value() == string.Empty ? 0 : x["availEq"].Value() + }) + .ToDictionary(k => k.Currency, v => v.AvailableEquity); + + return includeZeroBalances + ? availableEquity + : availableEquity + .Where(x => x.Value > 0) + .ToDictionary(k => k.Key, v => v.Value); + } + + protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol) + { + var token = await MakeJsonRequestAsync("/trade/orders-pending", BaseUrlV5, + await GetNoncePayloadAsync()); + return ParseOrders(token); + } + + protected override async Task OnGetOrderDetailsAsync(string orderId, + string marketSymbol, bool isClientOrderId = false) + { + if (string.IsNullOrEmpty(marketSymbol)) + { + throw new ArgumentNullException(nameof(marketSymbol), + "Okex single order details request requires symbol"); + } + + if (string.IsNullOrEmpty(orderId)) + { + throw new ArgumentNullException(nameof(orderId), + "Okex single order details request requires order ID or client-supplied order ID"); + } + + var param = isClientOrderId ? $"clOrdId={orderId}" : $"ordId={orderId}"; + var token = await MakeJsonRequestAsync($"/trade/order?{param}&instId={marketSymbol}", BaseUrlV5, + await GetNoncePayloadAsync()); + + return ParseOrders(token).First(); + } + + protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol) + { + if (string.IsNullOrEmpty(orderId)) + { + throw new ArgumentNullException(nameof(orderId), "Okex cancel order request requires order ID"); + } + + if (string.IsNullOrEmpty(marketSymbol)) + { + throw new ArgumentNullException(nameof(marketSymbol), "Okex cancel order request requires symbol"); + } + + var payload = await GetNoncePayloadAsync(); + payload["ordId"] = orderId; + payload["instId"] = marketSymbol; + await MakeJsonRequestAsync("/trade/cancel-order", BaseUrlV5, payload, "POST"); + } + + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + if (string.IsNullOrEmpty(order.MarketSymbol)) + { + throw new ArgumentNullException(nameof(order.MarketSymbol), "Okex place order request requires symbol"); + } + + var payload = await GetNoncePayloadAsync(); + payload["instId"] = order.MarketSymbol; + payload["tdMode"] = order.IsMargin ? "isolated" : "cash"; + if (!string.IsNullOrEmpty(order.ClientOrderId)) + { + payload["clOrdId"] = order.ClientOrderId; + } + payload["side"] = order.IsBuy ? "buy" : "sell"; + payload["posSide"] = "net"; + payload["ordType"] = order.OrderType switch + { + OrderType.Limit => "limit", + OrderType.Market => "market", + OrderType.Stop => throw new ArgumentException("Okex does not support stop order", + nameof(order.OrderType)), + _ => throw new ArgumentOutOfRangeException(nameof(order.OrderType), "Invalid order type.") + }; + 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"); + payload["px"] = order.Price.ToStringInvariant(); + } + + var token = await MakeJsonRequestAsync("/trade/order", BaseUrlV5, payload, "POST"); + return new ExchangeOrderResult() + { + MarketSymbol = order.MarketSymbol, + Amount = order.Amount, + Price = order.Price, + OrderDate = DateTime.UtcNow, + OrderId = token[0]["ordId"].Value(), + ClientOrderId = token[0]["clOrdId"].Value(), + Result = ExchangeAPIOrderResult.Open, + IsBuy = order.IsBuy + }; + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (!CanMakeAuthenticatedRequest(payload)) return; + // We don't need nonce in the request. Using it only to not break CanMakeAuthenticatedRequest. + payload.Remove("nonce"); + + var method = request.Method; + var now = DateTime.Now; + var timeStamp = TimeZoneInfo.ConvertTimeToUtc(now).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var requestUrl = request.RequestUri.PathAndQuery; + var body = payload.Any() ? JsonConvert.SerializeObject(payload) : string.Empty; + + var sign = string.IsNullOrEmpty(body) + ? CryptoUtility.SHA256SignBase64($"{timeStamp}{method}{requestUrl}", + PrivateApiKey!.ToUnsecureString().ToBytesUTF8()) + : CryptoUtility.SHA256SignBase64($"{timeStamp}{method}{requestUrl}{body}", + PrivateApiKey!.ToUnsecureString().ToBytesUTF8()); + + request.AddHeader("OK-ACCESS-KEY", PublicApiKey!.ToUnsecureString()); + request.AddHeader("OK-ACCESS-SIGN", sign); + request.AddHeader("OK-ACCESS-TIMESTAMP", timeStamp); + request.AddHeader("OK-ACCESS-PASSPHRASE", Passphrase!.ToUnsecureString()); + request.AddHeader("x-simulated-trading", "0"); + request.AddHeader("content-type", "application/json"); + + if (request.Method == "POST") + { + await request.WritePayloadJsonToRequestAsync(payload); + } + } + + private async Task GetBalance() + { + return await MakeJsonRequestAsync("/account/balance", BaseUrlV5, await GetNoncePayloadAsync()); + } + + private IEnumerable ParseOrders(JToken token) + => token.Select(x => + new ExchangeOrderResult() + { + 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() + }); + private async Task ParseTickerV5Async(JToken t, string symbol) { return await this.ParseTickerAsync(