From aa2b19ef00ecdfc1e5f4c164176211507164a9d8 Mon Sep 17 00:00:00 2001 From: BZ-CO <30245815+BZ-CO@users.noreply.github.com> Date: Thu, 2 Mar 2023 23:46:47 +0200 Subject: [PATCH] Migration to the new Poloniex API [Private endpoints] --- .../Exchanges/Poloniex/ExchangePoloniexAPI.cs | 1698 ++++++++--------- .../ExchangePoloniexAPITests.cs | 4 +- 2 files changed, 849 insertions(+), 853 deletions(-) diff --git a/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs b/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs index dc060b77..22dc2fb4 100644 --- a/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs @@ -11,50 +11,48 @@ The above copyright notice and this permission notice shall be included in all c */ using System.Diagnostics; -using System.Web; namespace ExchangeSharp { - using Newtonsoft.Json.Linq; - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - - public sealed partial class ExchangePoloniexAPI : ExchangeAPI - { - public override string BaseUrl { get; set; } = "https://api.poloniex.com"; - public override string BaseUrlWebSocket { get; set; } = "wss://api2.poloniex.com"; + using Newtonsoft.Json.Linq; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + + public sealed partial class ExchangePoloniexAPI : ExchangeAPI + { + public override string BaseUrl { get; set; } = "https://api.poloniex.com"; + public override string BaseUrlWebSocket { get; set; } = "wss://api2.poloniex.com"; private ExchangePoloniexAPI() - { - RequestContentType = "application/x-www-form-urlencoded"; - MarketSymbolSeparator = "_"; - MarketSymbolIsReversed = true; - WebSocketOrderBookType = WebSocketOrderBookType.DeltasOnly; - } - - /// - /// Number of fields Poloniex provides for withdrawals since specifying - /// extra content in the API request won't be rejected and may cause withdraweal to get stuck. - /// - public static IReadOnlyDictionary WithdrawalFieldCount { get; set; } - - private async Task MakePrivateAPIRequestAsync(string command, IReadOnlyList parameters = null) - { - Dictionary payload = await GetNoncePayloadAsync(); - payload["command"] = command; - if (parameters != null && parameters.Count % 2 == 0) - { - for (int i = 0; i < parameters.Count;) - { - payload[parameters[i++].ToStringInvariant()] = parameters[i++]; - } - } - - return await MakeJsonRequestAsync("/tradingApi", null, payload); - } + { + RequestContentType = "application/json"; + MarketSymbolSeparator = "_"; + WebSocketOrderBookType = WebSocketOrderBookType.DeltasOnly; + } + + /// + /// Number of fields Poloniex provides for withdrawals since specifying + /// extra content in the API request won't be rejected and may cause withdraweal to get stuck. + /// + public static IReadOnlyDictionary WithdrawalFieldCount { get; set; } + + private async Task MakePrivateAPIRequestAsync(string command, IReadOnlyList parameters = null) + { + Dictionary payload = await GetNoncePayloadAsync(); + payload["command"] = command; + if (parameters != null && parameters.Count % 2 == 0) + { + for (int i = 0; i < parameters.Count;) + { + payload[parameters[i++].ToStringInvariant()] = parameters[i++]; + } + } + + return await MakeJsonRequestAsync("/tradingApi", null, payload); + } /// protected override Task OnInitializeAsync() @@ -62,7 +60,9 @@ protected override Task OnInitializeAsync() // load withdrawal field counts var fieldCount = new Dictionary(StringComparer.OrdinalIgnoreCase); - using var resourceStream = typeof(ExchangePoloniexAPI).Assembly.GetManifestResourceStream("ExchangeSharp.Properties.Resources.PoloniexWithdrawalFields.csv"); + using var resourceStream = + typeof(ExchangePoloniexAPI).Assembly.GetManifestResourceStream( + "ExchangeSharp.Properties.Resources.PoloniexWithdrawalFields.csv"); Debug.Assert(resourceStream != null, nameof(resourceStream) + " != null"); using var sr = new StreamReader(resourceStream); @@ -109,33 +109,28 @@ public ExchangeOrderResult ParsePlacedOrder(JToken result, ExchangeOrderRequest return order; } - /// - /// Parses an order which has not been filled. - /// - /// The JToken to parse. - /// Market symbol or null if it's in the result - /// ExchangeOrderResult with the open order and how much is remaining to fill - public ExchangeOrderResult ParseOpenOrder(JToken result, string marketSymbol = null) - { - ExchangeOrderResult order = new ExchangeOrderResult - { - Amount = result["startingAmount"].ConvertInvariant(), - IsBuy = result["type"].ToStringLowerInvariant() != "sell", - OrderDate = result["date"].ToDateTimeInvariant(), - OrderId = result["orderNumber"].ToStringInvariant(), - Price = result["rate"].ConvertInvariant(), - Result = ExchangeAPIOrderResult.Open, - MarketSymbol = (marketSymbol ?? result.Parent.Path) - }; - - decimal amount = result["amount"].ConvertInvariant(); - order.AmountFilled = amount - order.Amount; - - // fee is a percentage taken from the traded amount rounded to 8 decimals - order.Fees = CalculateFees(amount, order.Price.Value, order.IsBuy, result["fee"].ConvertInvariant()); - - return order; - } + public static ExchangeOrderResult ParseOrder(JToken result, string marketSymbol = null) + { + var order = new ExchangeOrderResult + { + Amount = result["quantity"].ConvertInvariant(), + AmountFilled = result["filledQuantity"].ConvertInvariant(), + IsBuy = result["side"].ToStringLowerInvariant() != "sell", + OrderDate = result["createTime"].ConvertInvariant().UnixTimeStampToDateTimeMilliseconds(), + OrderId = result["id"].ToStringInvariant(), + Price = result["price"].ConvertInvariant(), + AveragePrice = result["avgPrice"].ConvertInvariant(), + Result = ParseOrderStatus(result["state"].ToStringInvariant()), + MarketSymbol = result["symbol"].ToStringInvariant(), + ClientOrderId = result["clientOrderId"].ToStringInvariant() + }; + + // fee is a percentage taken from the traded amount rounded to 8 decimals + order.Fees = CalculateFees(order.Amount, order.Price.Value, order.IsBuy, + result["fee"].ConvertInvariant()); + + return order; + } public void ParseOrderTrades(IEnumerable trades, ExchangeOrderResult order) { @@ -150,14 +145,17 @@ public void ParseOrderTrades(IEnumerable trades, ExchangeOrderResult ord { parsedSymbol = trade.Parent.Path; } + if (order.MarketSymbol == "all" || !string.IsNullOrWhiteSpace(parsedSymbol)) { order.MarketSymbol = parsedSymbol; } + if (!string.IsNullOrWhiteSpace(order.MarketSymbol)) { order.FeesCurrency = ParseFeesCurrency(order.IsBuy, order.MarketSymbol); } + orderMetadataSet = true; } @@ -194,160 +192,162 @@ public void ParseOrderTrades(IEnumerable trades, ExchangeOrderResult ord { order.Result = ExchangeAPIOrderResult.FilledPartially; } + // Poloniex does not provide a way to get the original price order.AveragePrice = order.AveragePrice?.Normalize(); order.Price = order.AveragePrice; } - public void ParseClosePositionTrades(IEnumerable trades, ExchangeCloseMarginPositionResult closePosition) - { - bool closePositionMetadataSet = false; - var tradeIds = new List(); - foreach (JToken trade in trades) - { - if (!closePositionMetadataSet) - { - closePosition.IsBuy = trade["type"].ToStringLowerInvariant() != "sell"; - - if (!string.IsNullOrWhiteSpace(closePosition.MarketSymbol)) - { - closePosition.FeesCurrency = ParseFeesCurrency(closePosition.IsBuy, closePosition.MarketSymbol); - } - - closePositionMetadataSet = true; - } - - decimal tradeAmt = trade["amount"].ConvertInvariant(); - decimal tradeRate = trade["rate"].ConvertInvariant(); - - closePosition.AveragePrice = (closePosition.AveragePrice * closePosition.AmountFilled + tradeAmt * tradeRate) / (closePosition.AmountFilled + tradeAmt); - closePosition.AmountFilled += tradeAmt; - - tradeIds.Add(trade["tradeID"].ToStringInvariant()); - - if (closePosition.CloseDate == DateTime.MinValue) - { - closePosition.CloseDate = trade["date"].ToDateTimeInvariant(); - } - - // fee is a percentage taken from the traded amount rounded to 8 decimals - closePosition.Fees += CalculateFees(tradeAmt, tradeRate, closePosition.IsBuy, trade["fee"].ConvertInvariant()); - } - - closePosition.TradeIds = tradeIds.ToArray(); - } - - private static decimal CalculateFees(decimal tradeAmt, decimal tradeRate, bool isBuy, decimal fee) - { - decimal amount = isBuy ? tradeAmt * fee : tradeAmt * tradeRate * fee; - return Math.Round(amount, 8, MidpointRounding.AwayFromZero); - } - - private void ParseCompletedOrderDetails(List orders, JToken trades, string marketSymbol) - { - IEnumerable orderNumsInTrades = trades.Select(x => x["orderNumber"].ToStringInvariant()).Distinct(); - foreach (string orderNum in orderNumsInTrades) - { - IEnumerable tradesForOrder = trades.Where(x => x["orderNumber"].ToStringInvariant() == orderNum); - ExchangeOrderResult order = new ExchangeOrderResult { OrderId = orderNum, MarketSymbol = marketSymbol }; - ParseOrderTrades(tradesForOrder, order); - //order.Price = order.AveragePrice; - order.Result = ExchangeAPIOrderResult.Filled; - orders.Add(order); - } - } - - private async Task ParseTickerWebSocketAsync(string symbol, JToken token) - { - /* - last: args[1], - lowestAsk: args[2], - highestBid: args[3], - percentChange: args[4], - baseVolume: args[5], - quoteVolume: args[6], - isFrozen: args[7], - high24hr: args[8], - low24hr: args[9] - */ - return await this.ParseTickerAsync(token, symbol, 2, 3, 1, 5, 6); - } - - public override string PeriodSecondsToString(int seconds) - { - var allowedPeriods = new[] - { - "MINUTE_1", "MINUTE_5", "MINUTE_10", "MINUTE_15", - "MINUTE_30", "HOUR_1", "HOUR_2", "HOUR_4", "HOUR_6", - "HOUR_12", "DAY_1", "DAY_3", "WEEK_1", "MONTH_1" - }; - var period = CryptoUtility.SecondsToPeriodStringLongReverse(seconds); - var periodIsvalid = allowedPeriods.Any(x => x == period); - if (!periodIsvalid) throw new ArgumentOutOfRangeException(nameof(period), $"{period} is not valid period on Poloniex"); - - return period; - } - - protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) - { - if (CanMakeAuthenticatedRequest(payload)) - { - payload["signTimestamp"] = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds(); - var form = payload.GetFormForPayload(); - var sig = $"{request.Method}\n" + - $"{request.RequestUri.PathAndQuery}\n" + - $"{HttpUtility.UrlEncode(form)}"; - request.AddHeader("key", PublicApiKey.ToUnsecureString()); - request.AddHeader("signature", CryptoUtility.SHA256Sign(sig, PrivateApiKey.ToUnsecureString())); - request.AddHeader("signTimestamp", payload["signTimestamp"].ToStringInvariant()); - await request.WriteToRequestAsync(form); - } - } - - protected override async Task> OnGetCurrenciesAsync() - { - /* - * {"1CR":{"id":1,"name":"1CRedit","txFee":"0.01000000","minConf":3,"depositAddress":null,"disabled":0,"delisted":1,"frozen":0}, - * "XC":{"id":230,"name":"XCurrency","txFee":"0.01000000","minConf":12,"depositAddress":null,"disabled":1,"delisted":1,"frozen":0}, - * ... } - */ - var currencies = new Dictionary(); - Dictionary currencyMap = await MakeJsonRequestAsync>("/public?command=returnCurrencies"); - foreach (var kvp in currencyMap) - { - var currency = new ExchangeCurrency - { - BaseAddress = kvp.Value["depositAddress"].ToStringInvariant(), - FullName = kvp.Value["name"].ToStringInvariant(), - DepositEnabled = true, - WithdrawalEnabled = true, - MinConfirmations = kvp.Value["minConf"].ConvertInvariant(), - Name = kvp.Key, - TxFee = kvp.Value["txFee"].ConvertInvariant(), - }; - - string disabled = kvp.Value["disabled"].ToStringInvariant(); - string delisted = kvp.Value["delisted"].ToStringInvariant(); - string frozen = kvp.Value["frozen"].ToStringInvariant(); - if (string.Equals(disabled, "1") || string.Equals(delisted, "1") || string.Equals(frozen, "1")) - { - currency.DepositEnabled = false; - currency.WithdrawalEnabled = false; - } - - currencies[currency.Name] = currency; - } - - return currencies; - } - - protected override async Task> OnGetMarketSymbolsAsync() - { - return (await GetMarketSymbolsMetadataAsync()).Where(x => x.IsActive.Value).Select(x => x.MarketSymbol); - } - - protected internal override async Task> OnGetMarketSymbolsMetadataAsync() - { + private static decimal CalculateFees(decimal tradeAmt, decimal tradeRate, bool isBuy, decimal fee) + { + decimal amount = isBuy ? tradeAmt * fee : tradeAmt * tradeRate * fee; + return Math.Round(amount, 8, MidpointRounding.AwayFromZero); + } + + private static IEnumerable ParseCompletedOrderDetails(JToken tradeHistory) + => tradeHistory + .Select(o => new ExchangeOrderResult + { + OrderId = o["orderId"].ToStringInvariant(), + MarketSymbol = o["symbol"].ToStringInvariant(), + Amount = o["quantity"].ConvertInvariant(), + IsBuy = o["side"].ToStringLowerInvariant() != "sell", + OrderDate = o["createTime"].ConvertInvariant().UnixTimeStampToDateTimeMilliseconds(), + Price = o["price"].ConvertInvariant(), + Result = ExchangeAPIOrderResult.Filled, + ClientOrderId = o["clientOrderId"].ToStringInvariant(), + Fees = o["feeAmount"].ConvertInvariant(), + FeesCurrency = o["feeCurrency"].ToStringInvariant() + }); + + private async Task ParseTickerWebSocketAsync(string symbol, JToken token) + { + /* + last: args[1], + lowestAsk: args[2], + highestBid: args[3], + percentChange: args[4], + baseVolume: args[5], + quoteVolume: args[6], + isFrozen: args[7], + high24hr: args[8], + low24hr: args[9] + */ + return await this.ParseTickerAsync(token, symbol, 2, 3, 1, 5, 6); + } + + public override string PeriodSecondsToString(int seconds) + { + var allowedPeriods = new[] + { + "MINUTE_1", "MINUTE_5", "MINUTE_10", "MINUTE_15", + "MINUTE_30", "HOUR_1", "HOUR_2", "HOUR_4", "HOUR_6", + "HOUR_12", "DAY_1", "DAY_3", "WEEK_1", "MONTH_1" + }; + var period = CryptoUtility.SecondsToPeriodStringLongReverse(seconds); + var periodIsvalid = allowedPeriods.Any(x => x == period); + if (!periodIsvalid) + throw new ArgumentOutOfRangeException(nameof(period), $"{period} is not valid period on Poloniex"); + + return period; + } + + protected override async Task ProcessRequestAsync(IHttpWebRequest request, Dictionary payload) + { + if (CanMakeAuthenticatedRequest(payload)) + { + payload.Remove("nonce"); + var timestamp = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds(); + var sig = string.Empty; + switch (request.Method) + { + case "GET": + case "DELETE": + { + payload["signTimestamp"] = timestamp; + var form = payload.GetFormForPayload(); + sig = $"{request.Method}\n" + + $"{request.RequestUri.PathAndQuery}\n" + + $"{form}"; + + await request.WriteToRequestAsync(form); + break; + } + case "POST": + { + var pl = $"requestBody={payload.GetJsonForPayload()}&signTimestamp={timestamp}"; + sig = $"{request.Method}\n" + + $"{request.RequestUri.PathAndQuery}\n" + + $"{pl}"; + await request.WritePayloadJsonToRequestAsync(payload); + break; + } + } + + request.AddHeader("key", PublicApiKey.ToUnsecureString()); + request.AddHeader("signature", + CryptoUtility.SHA256SignBase64(sig, PrivateApiKey.ToUnsecureBytesUTF8())); + request.AddHeader("signTimestamp", timestamp.ToStringInvariant()); + } + } + + protected override async Task> OnGetCurrenciesAsync() + { + //https://api.poloniex.com/v2/currencies + // [ + // { + // "id": 1, + // "coin": "1CR", + // "delisted": true, + // "tradeEnable": false, + // "name": "1CRedit", + // "networkList": [ + // { + // "id": 1, + // "coin": "1CR", + // "name": "1CRedit", + // "currencyType": "address", + // "blockchain": "1CR", + // "withdrawalEnable": false, + // "depositEnable": false, + // "depositAddress": null, + // "withdrawMin": null, + // "decimals": 8, + // "withdrawFee": "0.01000000", + // "minConfirm": 10000 + // } + // ] + // } + // ] + + var currencies = new Dictionary(); + var result = await MakeJsonRequestAsync("/v2/currencies"); + foreach (var c in result) + { + var currency = new ExchangeCurrency + { + Name = c["coin"].ToStringInvariant(), + FullName = c["name"].ToStringInvariant(), + TxFee = c["networkList"][0]["withdrawFee"].ConvertInvariant(), + DepositEnabled = c["networkList"][0]["depositEnable"].ConvertInvariant(), + WithdrawalEnabled = c["networkList"][0]["withdrawalEnable"].ConvertInvariant(), + MinWithdrawalSize = (c["networkList"][0]["withdrawMin"] ?? 0).ConvertInvariant(), + BaseAddress = (c["networkList"][0]["depositAddress"] ?? string.Empty).ToStringInvariant(), + MinConfirmations = (c["networkList"][0]["minConfirm"] ?? 0).ConvertInvariant() + }; + currencies[currency.Name] = currency; + } + + return currencies; + } + + protected override async Task> OnGetMarketSymbolsAsync() + { + return (await GetMarketSymbolsMetadataAsync()).Where(x => x.IsActive.Value).Select(x => x.MarketSymbol); + } + + protected internal override async Task> OnGetMarketSymbolsMetadataAsync() + { //https://api.poloniex.com/markets // [ // { @@ -374,109 +374,116 @@ protected internal override async Task> OnGetMarketS // } // ] - var markets = new List(); - var symbols = await MakeJsonRequestAsync("/markets"); - - foreach (var symbol in symbols) - { - var market = new ExchangeMarket - { - MarketSymbol = symbol["symbol"].ToStringInvariant(), - IsActive = ParsePairState(symbol["state"].ToStringInvariant()), - BaseCurrency = symbol["baseCurrencyName"].ToStringInvariant(), - QuoteCurrency = symbol["quoteCurrencyName"].ToStringInvariant(), - MinTradeSize = symbol["symbolTradeLimit"]["minQuantity"].Value(), - MinTradeSizeInQuoteCurrency = symbol["symbolTradeLimit"]["minAmount"].Value(), - PriceStepSize = CryptoUtility.PrecisionToStepSize(symbol["symbolTradeLimit"]["priceScale"].Value()), - QuantityStepSize = CryptoUtility.PrecisionToStepSize(symbol["symbolTradeLimit"]["quantityScale"].Value()), - MarginEnabled = symbol["crossMargin"]["supportCrossMargin"].Value() - }; - markets.Add(market); - } - - return markets; - } - - protected override async Task OnGetTickerAsync(string marketSymbol) - { - IEnumerable> tickers = await GetTickersAsync(); - foreach (var kv in tickers) - { - if (kv.Key == marketSymbol) - { - return kv.Value; - } - } - return null; - } - - protected override async Task>> OnGetTickersAsync() - { - //https://api.poloniex.com/markets/ticker24h - // [ { - // "symbol" : "BTS_BTC", - // "open" : "0.0000005026", - // "low" : "0.0000004851", - // "high" : "0.0000005799", - // "close" : "0.0000004851", - // "quantity" : "34444", - // "amount" : "0.0179936481", - // "tradeCount" : 48, - // "startTime" : 1676918100000, - // "closeTime" : 1677004501011, - // "displayName" : "BTS/BTC", - // "dailyChange" : "-0.0348", - // "bid" : "0.0000004852", - // "bidQuantity" : "725", - // "ask" : "0.0000004962", - // "askQuantity" : "238", - // "ts" : 1677004503839, - // "markPrice" : "0.000000501" - // }] - var tickers = new List>(); - var tickerResponse = await MakeJsonRequestAsync("/markets/ticker24h"); - foreach (var instrument in tickerResponse) - { - var symbol = instrument["symbol"].ToStringInvariant(); - var ticker = await this.ParseTickerAsync( - instrument, symbol, askKey: "ask", bidKey: "bid", baseVolumeKey: "quantity", lastKey: "close", - quoteVolumeKey: "amount", timestampKey: "ts", timestampType: TimestampType.UnixMilliseconds); - tickers.Add(new KeyValuePair(symbol, ticker)); - } - - return tickers; - } - - protected override async Task OnGetTickersWebSocketAsync(Action>> callback, params string[] symbols) - { - Dictionary idsToSymbols = new Dictionary(); - return await ConnectPublicWebSocketAsync(string.Empty, async (_socket, msg) => - { - JToken token = JToken.Parse(msg.ToStringFromUTF8()); - if (token[0].ConvertInvariant() == 1002) - { - if (token is JArray outerArray && outerArray.Count > 2 && outerArray[2] is JArray array && array.Count > 9 && - idsToSymbols.TryGetValue(array[0].ToStringInvariant(), out string symbol)) - { - callback.Invoke(new List> - { - new KeyValuePair(symbol, await ParseTickerWebSocketAsync(symbol, array)) - }); - } - } - }, async (_socket) => - { - var tickers = await GetTickersAsync(); - foreach (var ticker in tickers) - { - idsToSymbols[ticker.Value.Id] = ticker.Key; - } - // subscribe to ticker channel (1002) - await _socket.SendMessageAsync(new { command = "subscribe", channel = 1002 }); - }); - } - - protected override async Task OnGetTradesWebSocketAsync(Func, Task> callback, params string[] marketSymbols) + var symbols = await MakeJsonRequestAsync("/markets"); + + return symbols.Select(symbol => new ExchangeMarket + { + MarketSymbol = symbol["symbol"].ToStringInvariant(), + IsActive = ParsePairState(symbol["state"].ToStringInvariant()), + BaseCurrency = symbol["baseCurrencyName"].ToStringInvariant(), + QuoteCurrency = symbol["quoteCurrencyName"].ToStringInvariant(), + MinTradeSize = symbol["symbolTradeLimit"]["minQuantity"].Value(), + MaxTradeSize = decimal.MaxValue, + MinTradeSizeInQuoteCurrency = symbol["symbolTradeLimit"]["minAmount"].Value(), + MinPrice = CryptoUtility.PrecisionToStepSize(symbol["symbolTradeLimit"]["priceScale"] + .Value()), + MaxPrice = decimal.MaxValue, + PriceStepSize = + CryptoUtility.PrecisionToStepSize(symbol["symbolTradeLimit"]["priceScale"].Value()), + QuantityStepSize = + CryptoUtility.PrecisionToStepSize(symbol["symbolTradeLimit"]["quantityScale"].Value()), + MarginEnabled = symbol["crossMargin"]["supportCrossMargin"].Value() + }); + } + + protected override async Task OnGetTickerAsync(string marketSymbol) + { + var tickers = await GetTickersAsync(); + foreach (var kv in tickers) + { + if (kv.Key == marketSymbol) + { + return kv.Value; + } + } + + return null; + } + + protected override async Task>> OnGetTickersAsync() + { + //https://api.poloniex.com/markets/ticker24h + // [ { + // "symbol" : "BTS_BTC", + // "open" : "0.0000005026", + // "low" : "0.0000004851", + // "high" : "0.0000005799", + // "close" : "0.0000004851", + // "quantity" : "34444", + // "amount" : "0.0179936481", + // "tradeCount" : 48, + // "startTime" : 1676918100000, + // "closeTime" : 1677004501011, + // "displayName" : "BTS/BTC", + // "dailyChange" : "-0.0348", + // "bid" : "0.0000004852", + // "bidQuantity" : "725", + // "ask" : "0.0000004962", + // "askQuantity" : "238", + // "ts" : 1677004503839, + // "markPrice" : "0.000000501" + // }] + var tickers = new List>(); + var tickerResponse = await MakeJsonRequestAsync("/markets/ticker24h"); + foreach (var instrument in tickerResponse) + { + var symbol = instrument["symbol"].ToStringInvariant(); + var ticker = await this.ParseTickerAsync( + instrument, symbol, askKey: "ask", bidKey: "bid", baseVolumeKey: "quantity", lastKey: "close", + quoteVolumeKey: "amount", timestampKey: "ts", timestampType: TimestampType.UnixMilliseconds); + tickers.Add(new KeyValuePair(symbol, ticker)); + } + + return tickers; + } + + protected override async Task OnGetTickersWebSocketAsync( + Action>> callback, + params string[] symbols) + { + Dictionary idsToSymbols = new Dictionary(); + return await ConnectPublicWebSocketAsync(string.Empty, async (_socket, msg) => + { + JToken token = JToken.Parse(msg.ToStringFromUTF8()); + if (token[0].ConvertInvariant() == 1002) + { + if (token is JArray outerArray && outerArray.Count > 2 && outerArray[2] is JArray array && + array.Count > 9 && + idsToSymbols.TryGetValue(array[0].ToStringInvariant(), out string symbol)) + { + callback.Invoke(new List> + { + new KeyValuePair(symbol, + await ParseTickerWebSocketAsync(symbol, array)) + }); + } + } + }, async (_socket) => + { + var tickers = await GetTickersAsync(); + foreach (var ticker in tickers) + { + idsToSymbols[ticker.Value.Id] = ticker.Key; + } + + // subscribe to ticker channel (1002) + await _socket.SendMessageAsync(new { command = "subscribe", channel = 1002 }); + }); + } + + protected override async Task OnGetTradesWebSocketAsync( + Func, Task> callback, + params string[] marketSymbols) { Dictionary messageIdToSymbol = new Dictionary(); Dictionary symbolToMessageId = new Dictionary(); @@ -486,6 +493,7 @@ protected override async Task OnGetTradesWebSocketAsync(Func { JToken token = JToken.Parse(msg.ToStringFromUTF8()); @@ -495,7 +503,7 @@ protected override async Task OnGetTradesWebSocketAsync(Func OnGetTradesWebSocketAsync(Func", <1 for buy 0 for sell>, "", "", , ""] - ExchangeTrade trade = data.ParseTrade(amountKey: 4, priceKey: 3, typeKey: 2, timestampKey: 6, + ExchangeTrade trade = data.ParseTrade(amountKey: 4, priceKey: 3, typeKey: 2, + timestampKey: 6, timestampType: TimestampType.UnixMilliseconds, idKey: 1, typeKeyIsBuyValue: "1"); await callback(new KeyValuePair(symbol, trade)); } @@ -539,6 +549,7 @@ protected override async Task OnGetTradesWebSocketAsync(Func symbolToMessageId[s]); } + // subscribe to order book and trades channel for each symbol foreach (var id in marketIDs) { @@ -547,108 +558,120 @@ protected override async Task OnGetTradesWebSocketAsync(Func OnGetDeltaOrderBookWebSocketAsync(Action callback, int maxCount = 20, params string[] marketSymbols) - { - Dictionary> messageIdToSymbol = new Dictionary>(); - return await ConnectPublicWebSocketAsync(string.Empty, (_socket, msg) => - { - JToken token = JToken.Parse(msg.ToStringFromUTF8()); - int msgId = token[0].ConvertInvariant(); - - //return if this is a heartbeat message - if (msgId == 1010) - { - return Task.CompletedTask; - } - - var seq = token[1].ConvertInvariant(); - var dataArray = token[2]; - ExchangeOrderBook book = new ExchangeOrderBook(); - foreach (var data in dataArray) - { - var dataType = data[0].ToStringInvariant(); - if (dataType == "i") - { - var marketInfo = data[1]; - var market = marketInfo["currencyPair"].ToStringInvariant(); - messageIdToSymbol[msgId] = new Tuple(market, 0); - - // we are only returning the deltas, this would create a full order book which we don't want, but keeping it - // here for historical reference - /* - foreach (JProperty jprop in marketInfo["orderBook"][0].Cast()) - { - var depth = new ExchangeOrderPrice - { - Price = jprop.Name.ConvertInvariant(), - Amount = jprop.Value.ConvertInvariant() - }; - book.Asks[depth.Price] = depth; - } - foreach (JProperty jprop in marketInfo["orderBook"][1].Cast()) - { - var depth = new ExchangeOrderPrice - { - Price = jprop.Name.ConvertInvariant(), - Amount = jprop.Value.ConvertInvariant() - }; - book.Bids[depth.Price] = depth; - } - */ - } - else if (dataType == "o") - { - //removes or modifies an existing item on the order books - if (messageIdToSymbol.TryGetValue(msgId, out Tuple symbol)) - { - int type = data[1].ConvertInvariant(); - var depth = new ExchangeOrderPrice { Price = data[2].ConvertInvariant(), Amount = data[3].ConvertInvariant() }; - var list = (type == 1 ? book.Bids : book.Asks); - list[depth.Price] = depth; - book.MarketSymbol = symbol.Item1; - book.SequenceId = symbol.Item2 + 1; - messageIdToSymbol[msgId] = new Tuple(book.MarketSymbol, book.SequenceId); - } - } - else - { - continue; - } - } - if (book != null && (book.Asks.Count != 0 || book.Bids.Count != 0)) - { - callback(book); - } - return Task.CompletedTask; - }, async (_socket) => - { - if (marketSymbols == null || marketSymbols.Length == 0) - { - marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); - } - // subscribe to order book and trades channel for each symbol - foreach (var sym in marketSymbols) - { - await _socket.SendMessageAsync(new { command = "subscribe", channel = NormalizeMarketSymbol(sym) }); - } - }); - } - - protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) - { - //https://api.poloniex.com/markets/{symbol}/orderBook?scale={scale}&limit={limit} - // { - // "time" : 1677005825632, - // "scale" : "0.01", - // "asks" : [ "24702.89", "0.046082", "24702.90", "0.001681", "24703.09", "0.002037", "24710.10", "0.143572", "24712.18", "0.00118", "24713.68", "0.606951", "24724.80", "0.133", "24728.93", "0.7", "24728.94", "0.4", "24737.10", "0.135203" ], - // "bids" : [ "24700.03", "1.006472", "24700.02", "0.001208", "24698.71", "0.607319", "24697.99", "0.001973", "24688.50", "0.133", "24679.41", "0.4", "24679.40", "0.135", "24678.55", "0.3", "24667.00", "0.262", "24661.39", "0.14" ], - // "ts" : 1677005825637 - // } - var response = await MakeJsonRequestAsync($"/markets/{marketSymbol}/orderBook?limit={maxCount}"); - return response.ParseOrderBookFromJTokenArray(); - } - - protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = null) + protected override async Task OnGetDeltaOrderBookWebSocketAsync( + Action callback, + int maxCount = 20, + params string[] marketSymbols) + { + Dictionary> messageIdToSymbol = new Dictionary>(); + return await ConnectPublicWebSocketAsync(string.Empty, (_socket, msg) => + { + JToken token = JToken.Parse(msg.ToStringFromUTF8()); + int msgId = token[0].ConvertInvariant(); + + //return if this is a heartbeat message + if (msgId == 1010) + { + return Task.CompletedTask; + } + + var seq = token[1].ConvertInvariant(); + var dataArray = token[2]; + ExchangeOrderBook book = new ExchangeOrderBook(); + foreach (var data in dataArray) + { + var dataType = data[0].ToStringInvariant(); + if (dataType == "i") + { + var marketInfo = data[1]; + var market = marketInfo["currencyPair"].ToStringInvariant(); + messageIdToSymbol[msgId] = new Tuple(market, 0); + + // we are only returning the deltas, this would create a full order book which we don't want, but keeping it + // here for historical reference + /* + foreach (JProperty jprop in marketInfo["orderBook"][0].Cast()) + { + var depth = new ExchangeOrderPrice + { + Price = jprop.Name.ConvertInvariant(), + Amount = jprop.Value.ConvertInvariant() + }; + book.Asks[depth.Price] = depth; + } + foreach (JProperty jprop in marketInfo["orderBook"][1].Cast()) + { + var depth = new ExchangeOrderPrice + { + Price = jprop.Name.ConvertInvariant(), + Amount = jprop.Value.ConvertInvariant() + }; + book.Bids[depth.Price] = depth; + } + */ + } + else if (dataType == "o") + { + //removes or modifies an existing item on the order books + if (messageIdToSymbol.TryGetValue(msgId, out Tuple symbol)) + { + int type = data[1].ConvertInvariant(); + var depth = new ExchangeOrderPrice + { + Price = data[2].ConvertInvariant(), + Amount = data[3].ConvertInvariant() + }; + var list = (type == 1 ? book.Bids : book.Asks); + list[depth.Price] = depth; + book.MarketSymbol = symbol.Item1; + book.SequenceId = symbol.Item2 + 1; + messageIdToSymbol[msgId] = new Tuple(book.MarketSymbol, book.SequenceId); + } + } + else + { + continue; + } + } + + if (book != null && (book.Asks.Count != 0 || book.Bids.Count != 0)) + { + callback(book); + } + + return Task.CompletedTask; + }, async (_socket) => + { + if (marketSymbols == null || marketSymbols.Length == 0) + { + marketSymbols = (await GetMarketSymbolsAsync()).ToArray(); + } + + // subscribe to order book and trades channel for each symbol + foreach (var sym in marketSymbols) + { + await _socket.SendMessageAsync(new { command = "subscribe", channel = NormalizeMarketSymbol(sym) }); + } + }); + } + + protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) + { + //https://api.poloniex.com/markets/{symbol}/orderBook?scale={scale}&limit={limit} + // { + // "time" : 1677005825632, + // "scale" : "0.01", + // "asks" : [ "24702.89", "0.046082", "24702.90", "0.001681", "24703.09", "0.002037", "24710.10", "0.143572", "24712.18", "0.00118", "24713.68", "0.606951", "24724.80", "0.133", "24728.93", "0.7", "24728.94", "0.4", "24737.10", "0.135203" ], + // "bids" : [ "24700.03", "1.006472", "24700.02", "0.001208", "24698.71", "0.607319", "24697.99", "0.001973", "24688.50", "0.133", "24679.41", "0.4", "24679.40", "0.135", "24678.55", "0.3", "24667.00", "0.262", "24661.39", "0.14" ], + // "ts" : 1677005825637 + // } + var response = await MakeJsonRequestAsync($"/markets/{marketSymbol}/orderBook?limit={maxCount}"); + return response.ParseOrderBookFromJTokenArray(); + } + + protected override async Task> OnGetRecentTradesAsync( + string marketSymbol, + int? limit = null) { //https://api.poloniex.com/markets/{symbol}/trades?limit={limit} // Returns a list of recent trades, request param limit is optional, its default value is 500, and max value is 1000. @@ -678,284 +701,230 @@ protected override async Task> OnGetRecentTradesAsync return trades; } - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - //https://api.poloniex.com/markets/{symbol}/candles?interval={interval}&limit={limit}&startTime={startTime}&endTime={endTime} - // [ - // [ - // "45218", - // "47590.82", - // "47009.11", - // "45516.6", - // "13337805.8", - // "286.639111", - // "0", - // "0", - // 0, - // 0, - // "46531.7", - // "DAY_1", - // 1648684800000, - // 1648771199999 - // ] - // ] - limit = (limit == null || limit < 1 || limit > 500) ? 500 : limit; - var period = PeriodSecondsToString(periodSeconds); - var url = $"/markets/{marketSymbol}/candles?interval={period}&limit={limit}"; - 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, 2, 1, 0, 3, 12, TimestampType.UnixMilliseconds, - 5, 4, 10)) - .ToList(); - } - - protected override async Task> OnGetAmountsAsync() - { - // Dictionary payload = await GetNoncePayloadAsync(); - Dictionary payload = new Dictionary(); - var response = await MakeJsonRequestAsync("/accounts/balances", payload: payload); - return null; - } - - protected override async Task> OnGetAmountsAvailableToTradeAsync() - { - Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - JToken result = await MakePrivateAPIRequestAsync("returnBalances"); - foreach (JProperty child in result.Children()) - { - decimal amount = child.Value.ConvertInvariant(); - if (amount > 0m) - { - amounts[child.Name] = amount; - } - } - return amounts; - } - - protected override async Task> OnGetMarginAmountsAvailableToTradeAsync(bool includeZeroBalances) - { - Dictionary amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - var accountArgumentName = "account"; - var accountArgumentValue = "margin"; - JToken result = await MakePrivateAPIRequestAsync("returnAvailableAccountBalances", new object[] { accountArgumentName, accountArgumentValue }); - foreach (JProperty child in result[accountArgumentValue].Children()) - { - decimal amount = child.Value.ConvertInvariant(); - if (amount > 0m || includeZeroBalances) - { - amounts[child.Name] = amount; - } - } - return amounts; - } - - protected override async Task OnGetOpenPositionAsync(string marketSymbol) - { - List orderParams = new List - { - "currencyPair", marketSymbol - }; - - JToken result = await MakePrivateAPIRequestAsync("getMarginPosition", orderParams); - ExchangeMarginPositionResult marginPositionResult = new ExchangeMarginPositionResult() - { - Amount = result["amount"].ConvertInvariant(), - Total = result["total"].ConvertInvariant(), - BasePrice = result["basePrice"].ConvertInvariant(), - LiquidationPrice = result["liquidationPrice"].ConvertInvariant(), - ProfitLoss = result["pl"].ConvertInvariant(), - LendingFees = result["lendingFees"].ConvertInvariant(), - Type = result["type"].ToStringInvariant(), - MarketSymbol = marketSymbol - }; - return marginPositionResult; - } - - protected override async Task OnCloseMarginPositionAsync(string marketSymbol) - { - List orderParams = new List - { - "currencyPair", marketSymbol - }; - - JToken result = await MakePrivateAPIRequestAsync("closeMarginPosition", orderParams); - - ExchangeCloseMarginPositionResult closePositionResult = new ExchangeCloseMarginPositionResult() - { - Success = result["success"].ConvertInvariant(), - Message = result["message"].ToStringInvariant(), - MarketSymbol = marketSymbol - }; - - JToken symbolTrades = result["resultingTrades"]; - if (symbolTrades == null || !symbolTrades.Any()) - return closePositionResult; - - JToken trades = symbolTrades[marketSymbol]; - if (trades != null && trades.Children().Count() != 0) - { - ParseClosePositionTrades(trades, closePositionResult); - } - - return closePositionResult; - } - - protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) - { - if (order.OrderType == OrderType.Market) - { - throw new NotSupportedException("Order type " + order.OrderType + " not supported"); - } - - decimal orderAmount = await ClampOrderQuantity(order.MarketSymbol, order.Amount); - if (order.Price == null) throw new ArgumentNullException(nameof(order.Price)); - decimal orderPrice = await ClampOrderPrice(order.MarketSymbol, order.Price.Value); - - List orderParams = new List - { - "currencyPair", order.MarketSymbol, - "rate", orderPrice.ToStringInvariant(), - "amount", orderAmount.ToStringInvariant() - }; - if (order.IsPostOnly != null) { orderParams.Add("postOnly"); orderParams.Add(order.IsPostOnly.Value ? "1" : "0"); } // (optional) Set to "1" if you want this sell order to only be placed if no portion of it fills immediately. - foreach (KeyValuePair kv in order.ExtraParameters) - { - orderParams.Add(kv.Key); - orderParams.Add(kv.Value); - } - - JToken result = null; - try + protected override async Task> OnGetCandlesAsync( + string marketSymbol, + int periodSeconds, + DateTime? startDate = null, + DateTime? endDate = null, + int? limit = null) + { + //https://api.poloniex.com/markets/{symbol}/candles?interval={interval}&limit={limit}&startTime={startTime}&endTime={endTime} + // [ + // [ + // "45218", + // "47590.82", + // "47009.11", + // "45516.6", + // "13337805.8", + // "286.639111", + // "0", + // "0", + // 0, + // 0, + // "46531.7", + // "DAY_1", + // 1648684800000, + // 1648771199999 + // ] + // ] + limit = (limit == null || limit < 1 || limit > 500) ? 500 : limit; + var period = PeriodSecondsToString(periodSeconds); + var url = $"/markets/{marketSymbol}/candles?interval={period}&limit={limit}"; + if (startDate != null) { - result = await MakePrivateAPIRequestAsync(order.IsBuy ? (order.IsMargin ? "marginBuy" : "buy") : (order.IsMargin ? "marginSell" : "sell"), orderParams); + url = $"{url}&startTime={new DateTimeOffset(startDate.Value).ToUnixTimeMilliseconds()}"; } - catch (Exception e) + + if (endDate != null) { - if (!e.Message.Contains("Unable to fill order completely")) + url = $"{url}&endTime={new DateTimeOffset(endDate.Value).ToUnixTimeMilliseconds()}"; + } + + var candleResponse = await MakeJsonRequestAsync(url); + return candleResponse + .Select(cr => this.ParseCandle( + cr, marketSymbol, periodSeconds, 2, 1, 0, 3, 12, TimestampType.UnixMilliseconds, + 5, 4, 10)); + } + + protected override async Task> OnGetAmountsAsync() + { + var response = + await MakeJsonRequestAsync("/accounts/balances", payload: await GetNoncePayloadAsync()); + return (response[0]?["balances"] ?? throw new InvalidOperationException()) + .Select(x => new { - throw; - } - result = JToken.FromObject(new { orderNumber = "0", currencyPair = order.MarketSymbol }); + Currency = x["currency"].Value(), + TotalBalance = x["available"].Value() + x["hold"].Value() + }) + .ToDictionary(k => k.Currency, v => v.TotalBalance); + } + + protected override async Task> OnGetAmountsAvailableToTradeAsync() + { + var response = + await MakeJsonRequestAsync("/accounts/balances", payload: await GetNoncePayloadAsync()); + return (response[0]?["balances"] ?? throw new InvalidOperationException()) + .Select(x => new + { + Currency = x["currency"].Value(), + AvailableBalance = x["available"].Value() + }) + .ToDictionary(k => k.Currency, v => v.AvailableBalance); + } + + protected override async Task> OnGetMarginAmountsAvailableToTradeAsync( + bool includeZeroBalances) + { + var response = + await MakeJsonRequestAsync("/margin/borrowStatus", payload: await GetNoncePayloadAsync()); + var balances = response.Select(x => new + { + Currency = x["currency"].Value(), + AvailableBalance = x["available"].Value() + }); + if (includeZeroBalances) + { + balances = balances.Where(x => x.AvailableBalance > 0); + } + + return balances.ToDictionary(k => k.Currency, v => v.AvailableBalance); + } + + protected override async Task OnPlaceOrderAsync(ExchangeOrderRequest order) + { + if (order.Price == null && order.OrderType != OrderType.Market) + throw new ArgumentNullException(nameof(order.Price)); + var orderAmount = await ClampOrderQuantity(order.MarketSymbol, order.Amount); + var orderPrice = order.Price.GetValueOrDefault(); + if (order.OrderType != OrderType.Market) + { + orderPrice = await ClampOrderPrice(order.MarketSymbol, order.Price!.Value); + } + + var payload = await GetNoncePayloadAsync(); + + payload["symbol"] = order.MarketSymbol; + payload["side"] = order.IsBuy ? "BUY" : "SELL"; + payload["quantity"] = orderAmount; + + if (!string.IsNullOrEmpty(order.ClientOrderId)) + { + payload["clientOrderId"] = order.ClientOrderId; + } + + if (order.IsPostOnly.GetValueOrDefault()) + { + payload["type"] = "LIMIT_MAKER"; + } + + switch (order.OrderType) + { + case OrderType.Limit when !order.IsPostOnly.GetValueOrDefault(): + payload["type"] = "LIMIT"; + payload["price"] = orderPrice; + break; + case OrderType.Limit when order.IsPostOnly.GetValueOrDefault(): + payload["type"] = "LIMIT_MAKER"; + break; + case OrderType.Market: + payload["type"] = "MARKET"; + break; + case OrderType.Stop: + default: throw new ArgumentOutOfRangeException(nameof(order.OrderType)); + } + + foreach (var kvp in order.ExtraParameters) + { + payload[kvp.Key] = kvp.Value; } - ExchangeOrderResult exchangeOrderResult = ParsePlacedOrder(result, order); - exchangeOrderResult.MarketSymbol = order.MarketSymbol; - exchangeOrderResult.FeesCurrency = ParseFeesCurrency(order.IsBuy, order.MarketSymbol); - return exchangeOrderResult; - } - - protected override async Task> OnGetOpenOrderDetailsAsync(string marketSymbol = null) - { - if (marketSymbol.Length == 0) - { - marketSymbol = "all"; - } - - List orders = new List(); - JToken result = await MakePrivateAPIRequestAsync("returnOpenOrders", new object[] { "currencyPair", marketSymbol }); - if (marketSymbol == "all") - { - foreach (JProperty prop in result) - { - if (prop.Value is JArray array) - { - foreach (JToken token in array) - { - orders.Add(ParseOpenOrder(token, null)); - } - } - } - } - else if (result is JArray array) - { - foreach (JToken token in array) - { - orders.Add(ParseOpenOrder(token, marketSymbol)); - } - } - - return orders; - } - - protected override async Task OnGetOrderDetailsAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + + var response = await MakeJsonRequestAsync("/orders", payload: payload, requestMethod: "POST"); + var orderInfo = await GetOrderDetailsAsync(response["id"].ToStringInvariant()); + + return orderInfo; + } + + protected override async Task> OnGetOpenOrderDetailsAsync( + string marketSymbol = null) { - if (isClientOrderId) throw new NotSupportedException("Querying by client order ID is not implemented in ExchangeSharp. Please submit a PR if you are interested in this feature"); - JToken resultArray = await MakePrivateAPIRequestAsync("returnOrderTrades", new object[] { "orderNumber", orderId }); - string tickerSymbol = resultArray[0]["currencyPair"].ToStringInvariant(); - List orders = new List(); - ParseCompletedOrderDetails(orders, resultArray, tickerSymbol); - if (orders.Count != 1) - { - throw new APIException($"ReturnOrderTrades for a single orderNumber returned {orders.Count} orders. Expected 1."); - } - - orders[0].OrderId = orderId; - orders[0].Price = orders[0].AveragePrice; - return orders[0]; - } - - protected override async Task> OnGetCompletedOrderDetailsAsync(string marketSymbol = null, DateTime? afterDate = null) - { - marketSymbol = string.IsNullOrWhiteSpace(marketSymbol) ? "all" : NormalizeMarketSymbol(marketSymbol); - - List orders = new List(); - afterDate = afterDate ?? CryptoUtility.UtcNow.Subtract(TimeSpan.FromDays(365.0)); - long afterTimestamp = (long)afterDate.Value.UnixTimestampFromDateTimeSeconds(); - JToken result = await MakePrivateAPIRequestAsync("returnTradeHistory", new object[] { "currencyPair", marketSymbol, "limit", 10000, "start", afterTimestamp }); - if (marketSymbol != "all") - { - ParseCompletedOrderDetails(orders, result as JArray, marketSymbol); - } - else - { - foreach (JProperty prop in result) - { - ParseCompletedOrderDetails(orders, prop.Value as JArray, prop.Name); - } - } - return orders; - } - - protected override async Task OnCancelOrderAsync(string orderId, string marketSymbol = null, bool isClientOrderId = false) + var query = !string.IsNullOrEmpty(marketSymbol) ? $"?symbol={marketSymbol}" : ""; + var result = await MakeJsonRequestAsync($"/orders{query}", payload: await GetNoncePayloadAsync()); + + return result + .Select(o => ParseOrder(o, marketSymbol)).ToList(); + } + + protected override async Task OnGetOrderDetailsAsync( + string orderId, + string marketSymbol = null, + bool isClientOrderId = false) { - if (isClientOrderId) throw new NotSupportedException("Cancelling by client order ID is not supported in ExchangeSharp. Please submit a PR if you are interested in this feature"); - await MakePrivateAPIRequestAsync("cancelOrder", new object[] { "orderNumber", orderId.ConvertInvariant() }); - } - - protected override async Task OnWithdrawAsync(ExchangeWithdrawalRequest withdrawalRequest) - { - // If we have an address tag, verify that Polo lets you specify it as part of the withdrawal - if (!string.IsNullOrWhiteSpace(withdrawalRequest.AddressTag)) - { - if (!WithdrawalFieldCount.TryGetValue(withdrawalRequest.Currency, out int fieldCount) || fieldCount == 0) - { - throw new APIException($"Coin {withdrawalRequest.Currency} has unknown withdrawal field count. Please manually verify the number of fields allowed during a withdrawal (Address + Tag = 2) and add it to PoloniexWithdrawalFields.csv before calling Withdraw"); - } - else if (fieldCount == 1) - { - throw new APIException($"Coin {withdrawalRequest.Currency} only allows an address to be specified and address tag {withdrawalRequest.AddressTag} was provided."); - } - else if (fieldCount > 2) - { - throw new APIException("More than two fields on a withdrawal is unsupported."); - } - } - - var paramsList = new List { "currency", NormalizeMarketSymbol(withdrawalRequest.Currency), "amount", withdrawalRequest.Amount, "address", withdrawalRequest.Address }; - if (!string.IsNullOrWhiteSpace(withdrawalRequest.AddressTag)) - { - paramsList.Add("paymentId"); - paramsList.Add(withdrawalRequest.AddressTag); - } + var result = + await MakeJsonRequestAsync($"/orders/{orderId}", payload: await GetNoncePayloadAsync()); + return ParseOrder(result); + } + + protected override async Task> OnGetCompletedOrderDetailsAsync( + string marketSymbol = null, + DateTime? afterDate = null) + { + var query = !string.IsNullOrEmpty(marketSymbol) ? $"?symbol={marketSymbol}" : ""; + if (afterDate != null) + { + var startDateParam = $"startDate={new DateTimeOffset(afterDate.Value).ToUnixTimeMilliseconds()}"; + query = query.Length > 0 ? $"?{startDateParam}" : $"&{startDateParam}"; + } + + var result = await MakeJsonRequestAsync($"/trades{query}", payload: await GetNoncePayloadAsync()); + + return ParseCompletedOrderDetails(result); + } + + protected override async Task OnCancelOrderAsync( + string orderId, + string marketSymbol = null, + bool isClientOrderId = false) + { + await MakeJsonRequestAsync( + $"/orders/{orderId}", + payload: await GetNoncePayloadAsync(), + requestMethod: "DELETE"); + } + + protected override async Task OnWithdrawAsync( + ExchangeWithdrawalRequest withdrawalRequest) + { + // If we have an address tag, verify that Polo lets you specify it as part of the withdrawal + if (!string.IsNullOrWhiteSpace(withdrawalRequest.AddressTag)) + { + if (!WithdrawalFieldCount.TryGetValue(withdrawalRequest.Currency, out int fieldCount) || + fieldCount == 0) + { + throw new APIException( + $"Coin {withdrawalRequest.Currency} has unknown withdrawal field count. Please manually verify the number of fields allowed during a withdrawal (Address + Tag = 2) and add it to PoloniexWithdrawalFields.csv before calling Withdraw"); + } + else if (fieldCount == 1) + { + throw new APIException( + $"Coin {withdrawalRequest.Currency} only allows an address to be specified and address tag {withdrawalRequest.AddressTag} was provided."); + } + else if (fieldCount > 2) + { + throw new APIException("More than two fields on a withdrawal is unsupported."); + } + } + + var paramsList = new List + { + "currency", NormalizeMarketSymbol(withdrawalRequest.Currency), "amount", withdrawalRequest.Amount, + "address", withdrawalRequest.Address + }; + if (!string.IsNullOrWhiteSpace(withdrawalRequest.AddressTag)) + { + paramsList.Add("paymentId"); + paramsList.Add(withdrawalRequest.AddressTag); + } var token = await MakePrivateAPIRequestAsync("withdraw", paramsList.ToArray()); @@ -966,160 +935,187 @@ protected override async Task OnWithdrawAsync(Exchan }; } - protected override async Task OnGetDepositAddressAsync(string currency, bool forceRegenerate = false) - { - // Never reuse IOTA addresses - if (currency.Equals("MIOTA", StringComparison.OrdinalIgnoreCase)) - { - forceRegenerate = true; - } - - IReadOnlyDictionary currencies = await GetCurrenciesAsync(); - var depositAddresses = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!forceRegenerate && !(await TryFetchExistingAddresses(currency, currencies, depositAddresses))) - { - return null; - } - - if (!depositAddresses.TryGetValue(currency, out var depositDetails)) - { - depositDetails = await CreateDepositAddress(currency, currencies); - } - - return depositDetails; - } - - /// Gets the deposit history for a symbol - /// (ignored) The currency to check. - /// Collection of ExchangeCoinTransfers - protected override async Task> OnGetDepositHistoryAsync(string currency) - { - JToken result = await MakePrivateAPIRequestAsync("returnDepositsWithdrawals", - new object[] - { - "start", DateTime.MinValue.ToUniversalTime().UnixTimestampFromDateTimeSeconds(), - "end", CryptoUtility.UtcNow.UnixTimestampFromDateTimeSeconds() - }); - - var transactions = new List(); - - foreach (JToken token in result["deposits"]) - { - var deposit = new ExchangeTransaction - { - Currency = token["currency"].ToStringUpperInvariant(), - Address = token["address"].ToStringInvariant(), - Amount = token["amount"].ConvertInvariant(), - BlockchainTxId = token["txid"].ToStringInvariant(), - Timestamp = token["timestamp"].ConvertInvariant().UnixTimeStampToDateTimeSeconds() - }; - - string status = token["status"].ToStringUpperInvariant(); - switch (status) - { - case "COMPLETE": - deposit.Status = TransactionStatus.Complete; - break; - case "PENDING": - deposit.Status = TransactionStatus.Processing; - break; - default: - // TODO: API Docs don't specify what other options there will be for transaction status - deposit.Status = TransactionStatus.Unknown; - deposit.Notes = "Transaction status: " + status; - break; - } - - transactions.Add(deposit); - } - - return transactions; - } - - private static string ParseFeesCurrency(bool isBuy, string symbol) - { - string feesCurrency = null; - string[] currencies = symbol.Split('_'); - if (currencies.Length == 2) - { - // fees are in the "To" currency - feesCurrency = isBuy ? currencies[1] : currencies[0]; - } - - return feesCurrency; - } - - private async Task TryFetchExistingAddresses(string currency, IReadOnlyDictionary currencies, Dictionary depositAddresses) - { - JToken result = await MakePrivateAPIRequestAsync("returnDepositAddresses"); - foreach (JToken jToken in result) - { - var token = (JProperty)jToken; - var details = new ExchangeDepositDetails { Currency = token.Name }; - - if (!TryPopulateAddressAndTag(currency, currencies, details, token.Value.ToStringInvariant())) - { - return false; - } - - depositAddresses[details.Currency] = details; - } - - return true; - } - - private static bool TryPopulateAddressAndTag(string currency, IReadOnlyDictionary currencies, ExchangeDepositDetails details, string address) - { - if (currencies.TryGetValue(currency, out ExchangeCurrency coin)) - { - if (!string.IsNullOrWhiteSpace(coin.BaseAddress)) - { - details.Address = coin.BaseAddress; - details.AddressTag = address; - } - else - { - details.Address = address; - } - - return true; - } - - // Cannot find currency in master list. - // Stay safe and don't return a possibly half-baked deposit address missing a tag - return false; - - } - - private static bool ParsePairState(string state) - { - if (string.IsNullOrWhiteSpace(state)) return false; - - return state == "NORMAL"; - } - - /// - /// Create a deposit address - /// - /// Currency to create an address for - /// Lookup of existing currencies - /// ExchangeDepositDetails with an address or a BaseAddress/AddressTag pair. - private async Task CreateDepositAddress(string currency, IReadOnlyDictionary currencies) - { - JToken result = await MakePrivateAPIRequestAsync("generateNewAddress", new object[] { "currency", currency }); - var details = new ExchangeDepositDetails - { - Currency = currency, - }; - - if (!TryPopulateAddressAndTag(currency, currencies, details, result["response"].ToStringInvariant())) - { - return null; - } - - return details; - } - } - - public partial class ExchangeName { public const string Poloniex = "Poloniex"; } + protected override async Task OnGetDepositAddressAsync( + string currency, + bool forceRegenerate = false) + { + // Never reuse IOTA addresses + if (currency.Equals("MIOTA", StringComparison.OrdinalIgnoreCase)) + { + forceRegenerate = true; + } + + IReadOnlyDictionary currencies = await GetCurrenciesAsync(); + var depositAddresses = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!forceRegenerate && !(await TryFetchExistingAddresses(currency, currencies, depositAddresses))) + { + return null; + } + + if (!depositAddresses.TryGetValue(currency, out var depositDetails)) + { + depositDetails = await CreateDepositAddress(currency, currencies); + } + + return depositDetails; + } + + /// Gets the deposit history for a symbol + /// (ignored) The currency to check. + /// Collection of ExchangeCoinTransfers + protected override async Task> OnGetDepositHistoryAsync(string currency) + { + JToken result = await MakePrivateAPIRequestAsync("returnDepositsWithdrawals", + new object[] + { + "start", DateTime.MinValue.ToUniversalTime().UnixTimestampFromDateTimeSeconds(), + "end", CryptoUtility.UtcNow.UnixTimestampFromDateTimeSeconds() + }); + + var transactions = new List(); + + foreach (JToken token in result["deposits"]) + { + var deposit = new ExchangeTransaction + { + Currency = token["currency"].ToStringUpperInvariant(), + Address = token["address"].ToStringInvariant(), + Amount = token["amount"].ConvertInvariant(), + BlockchainTxId = token["txid"].ToStringInvariant(), + Timestamp = token["timestamp"].ConvertInvariant().UnixTimeStampToDateTimeSeconds() + }; + + string status = token["status"].ToStringUpperInvariant(); + switch (status) + { + case "COMPLETE": + deposit.Status = TransactionStatus.Complete; + break; + case "PENDING": + deposit.Status = TransactionStatus.Processing; + break; + default: + // TODO: API Docs don't specify what other options there will be for transaction status + deposit.Status = TransactionStatus.Unknown; + deposit.Notes = "Transaction status: " + status; + break; + } + + transactions.Add(deposit); + } + + return transactions; + } + + private static string ParseFeesCurrency(bool isBuy, string symbol) + { + string feesCurrency = null; + string[] currencies = symbol.Split('_'); + if (currencies.Length == 2) + { + // fees are in the "To" currency + feesCurrency = isBuy ? currencies[1] : currencies[0]; + } + + return feesCurrency; + } + + private async Task TryFetchExistingAddresses( + string currency, + IReadOnlyDictionary currencies, + Dictionary depositAddresses) + { + JToken result = await MakePrivateAPIRequestAsync("returnDepositAddresses"); + foreach (JToken jToken in result) + { + var token = (JProperty)jToken; + var details = new ExchangeDepositDetails { Currency = token.Name }; + + if (!TryPopulateAddressAndTag(currency, currencies, details, token.Value.ToStringInvariant())) + { + return false; + } + + depositAddresses[details.Currency] = details; + } + + return true; + } + + private static bool TryPopulateAddressAndTag( + string currency, + IReadOnlyDictionary currencies, + ExchangeDepositDetails details, + string address) + { + if (currencies.TryGetValue(currency, out ExchangeCurrency coin)) + { + if (!string.IsNullOrWhiteSpace(coin.BaseAddress)) + { + details.Address = coin.BaseAddress; + details.AddressTag = address; + } + else + { + details.Address = address; + } + + return true; + } + + // Cannot find currency in master list. + // Stay safe and don't return a possibly half-baked deposit address missing a tag + return false; + } + + private static bool ParsePairState(string state) + { + if (string.IsNullOrWhiteSpace(state)) return false; + + return state == "NORMAL"; + } + + private static ExchangeAPIOrderResult ParseOrderStatus(string status) => + status.ToUpperInvariant() switch + { + "NEW" => ExchangeAPIOrderResult.Open, + "PARTIALLY_FILLED" => ExchangeAPIOrderResult.FilledPartially, + "FILLED" => ExchangeAPIOrderResult.Filled, + "PENDING_CANCEL" => ExchangeAPIOrderResult.PendingCancel, + "PARTIALLY_CANCELED" => ExchangeAPIOrderResult.FilledPartiallyAndCancelled, + "CANCELED" => ExchangeAPIOrderResult.Canceled, + "FAILED" => ExchangeAPIOrderResult.Rejected, + _ => ExchangeAPIOrderResult.Unknown + }; + + /// + /// Create a deposit address + /// + /// Currency to create an address for + /// Lookup of existing currencies + /// ExchangeDepositDetails with an address or a BaseAddress/AddressTag pair. + private async Task CreateDepositAddress( + string currency, + IReadOnlyDictionary currencies) + { + JToken result = + await MakePrivateAPIRequestAsync("generateNewAddress", new object[] { "currency", currency }); + var details = new ExchangeDepositDetails + { + Currency = currency, + }; + + if (!TryPopulateAddressAndTag(currency, currencies, details, result["response"].ToStringInvariant())) + { + return null; + } + + return details; + } + } + + public partial class ExchangeName + { + public const string Poloniex = "Poloniex"; + } } diff --git a/tests/ExchangeSharpTests/ExchangePoloniexAPITests.cs b/tests/ExchangeSharpTests/ExchangePoloniexAPITests.cs index e5c231a9..66266dbd 100644 --- a/tests/ExchangeSharpTests/ExchangePoloniexAPITests.cs +++ b/tests/ExchangeSharpTests/ExchangePoloniexAPITests.cs @@ -187,7 +187,7 @@ public async Task ReturnOpenOrders_SingleMarket_Parses() { foreach (JToken token in array) { - orders.Add(polo.ParseOpenOrder(token)); + orders.Add(ExchangePoloniexAPI.ParseOrder(token)); } } @@ -202,7 +202,7 @@ public async Task ReturnOpenOrders_Unfilled_IsCorrect() { var polo = await CreatePoloniexAPI(); var marketOrders = JsonConvert.DeserializeObject(Unfilled); - ExchangeOrderResult order = polo.ParseOpenOrder(marketOrders[0]); + var order = ExchangePoloniexAPI.ParseOrder(marketOrders[0]); order.OrderId.Should().Be("35329211614"); order.IsBuy.Should().BeTrue(); order.AmountFilled.Should().Be(0);