diff --git a/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs b/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs index f61b8be7..dc060b77 100644 --- a/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs +++ b/src/ExchangeSharp/API/Exchanges/Poloniex/ExchangePoloniexAPI.cs @@ -11,6 +11,7 @@ The above copyright notice and this permission notice shall be included in all c */ using System.Diagnostics; +using System.Web; namespace ExchangeSharp { @@ -19,14 +20,11 @@ namespace ExchangeSharp using System.Collections.Generic; using System.IO; using System.Linq; - using System.Net; using System.Threading.Tasks; - using Newtonsoft.Json; - public sealed partial class ExchangePoloniexAPI : ExchangeAPI { - public override string BaseUrl { get; set; } = "https://poloniex.com"; + public override string BaseUrl { get; set; } = "https://api.poloniex.com"; public override string BaseUrlWebSocket { get; set; } = "wss://api2.poloniex.com"; private ExchangePoloniexAPI() @@ -275,15 +273,34 @@ private async Task ParseTickerWebSocketAsync(string symbol, JTok 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)) - { - string form = CryptoUtility.GetFormForPayload(payload); - request.AddHeader("Key", PublicApiKey.ToUnsecureString()); - request.AddHeader("Sign", CryptoUtility.SHA512Sign(form, PrivateApiKey.ToUnsecureString())); - request.Method = "POST"; - await CryptoUtility.WriteToRequestAsync(request, form); + 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); } } @@ -331,54 +348,50 @@ protected override async Task> OnGetMarketSymbolsAsync() protected internal override async Task> OnGetMarketSymbolsMetadataAsync() { - //https://docs.poloniex.com/#returnticker - /* - { - "BTC_BTS": { - "id": 14, - "last": "0.00000090", - "lowestAsk": "0.00000091", - "highestBid": "0.00000089", - "percentChange": "-0.02173913", - "baseVolume": "0.28698296", - "quoteVolume": "328356.84081156", - "isFrozen": "0", - "postOnly": "0", - "high24hr": "0.00000093", - "low24hr": "0.00000087" - },... - */ + //https://api.poloniex.com/markets + // [ + // { + // "symbol": "BTC_USDT", + // "baseCurrencyName": "BTC", + // "quoteCurrencyName": "USDT", + // "displayName": "BTC/USDT", + // "state": "NORMAL", + // "visibleStartTime": 1659018819512, + // "tradableStartTime": 1659018819512, + // "symbolTradeLimit": { + // "symbol": "BTC_USDT", + // "priceScale": 2, + // "quantityScale": 6, - base + // "amountScale": 2, - quote + // "minQuantity": "0.000001" - base, + // "minAmount": "1", - quote + // "highestBid": "0", + // "lowestAsk": "0" + // }, + // "crossMargin": { + // "supportCrossMargin": true, + // "maxLeverage": 3 + // } + // ] var markets = new List(); - Dictionary lookup = await MakeJsonRequestAsync>("/public?command=returnTicker"); - // StepSize is 8 decimal places for both price and amount on everything at Polo - const decimal StepSize = 0.00000001m; - const decimal minTradeSize = 0.0001m; + var symbols = await MakeJsonRequestAsync("/markets"); - foreach (var kvp in lookup) + foreach (var symbol in symbols) { - var market = new ExchangeMarket { MarketSymbol = kvp.Key, IsActive = false }; - - string isFrozen = kvp.Value["isFrozen"].ToStringInvariant(); - string postOnly = kvp.Value["postOnly"].ToStringInvariant(); - if (string.Equals(isFrozen, "0") && string.Equals(postOnly, "0")) - { - market.IsActive = true; - } - - string[] pairs = kvp.Key.Split('_'); - if (pairs.Length == 2) - { - market.MarketId = kvp.Value["id"].ToStringLowerInvariant(); - market.QuoteCurrency = pairs[0]; - market.BaseCurrency = pairs[1]; - market.PriceStepSize = StepSize; - market.QuantityStepSize = StepSize; - market.MinPrice = StepSize; - market.MinTradeSize = minTradeSize; - } - - markets.Add(market); + 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; @@ -399,17 +412,38 @@ protected override async Task OnGetTickerAsync(string marketSymb protected override async Task>> OnGetTickersAsync() { - // {"BTC_LTC":{"last":"0.0251","lowestAsk":"0.02589999","highestBid":"0.0251","percentChange":"0.02390438","baseVolume":"6.16485315","quoteVolume":"245.82513926"} - List> tickers = new List>(); - JToken obj = await MakeJsonRequestAsync("/public?command=returnTicker"); - foreach (JProperty prop in obj.Children()) + //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) { - string marketSymbol = prop.Name; - JToken values = prop.Value; - //NOTE: Poloniex uses the term "caseVolume" when referring to the QuoteCurrencyVolume - ExchangeTicker ticker = await this.ParseTickerAsync(values, marketSymbol, "lowestAsk", "highestBid", "last", "quoteVolume", "baseVolume", idKey: "id"); - tickers.Add(new KeyValuePair(marketSymbol, ticker)); + 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; } @@ -602,108 +636,95 @@ protected override async Task OnGetDeltaOrderBookWebSocketAsync(Acti protected override async Task OnGetOrderBookAsync(string marketSymbol, int maxCount = 100) { - // {"asks":[["0.01021997",22.83117932],["0.01022000",82.3204],["0.01022480",140],["0.01023054",241.06436945],["0.01023057",140]],"bids":[["0.01020233",164.195],["0.01020232",66.22565096],["0.01020200",5],["0.01020010",66.79296968],["0.01020000",490.19563761]],"isFrozen":"0","seq":147171861} - JToken token = await MakeJsonRequestAsync("/public?command=returnOrderBook¤cyPair=" + marketSymbol + "&depth=" + maxCount); - return ExchangeAPIExtensions.ParseOrderBookFromJTokenArrays(token); + //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>> OnGetOrderBooksAsync(int maxCount = 100) - { - List> books = new List>(); - JToken obj = await MakeJsonRequestAsync("/public?command=returnOrderBook¤cyPair=all&depth=" + maxCount); - foreach (JProperty token in obj.Children()) - { - ExchangeOrderBook book = new ExchangeOrderBook(); - foreach (JArray array in token.First["asks"]) - { - var depth = new ExchangeOrderPrice { Amount = array[1].ConvertInvariant(), Price = array[0].ConvertInvariant() }; - book.Asks[depth.Price] = depth; - } - foreach (JArray array in token.First["bids"]) - { - var depth = new ExchangeOrderPrice { Amount = array[1].ConvertInvariant(), Price = array[0].ConvertInvariant() }; - book.Bids[depth.Price] = depth; - } - books.Add(new KeyValuePair(token.Name, book)); - } - return books; - } - - protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = null) + protected override async Task> OnGetRecentTradesAsync(string marketSymbol, int? limit = null) { - List trades = new List(); - //https://docs.poloniex.com/#returnorderbook note poloniex limit = 1000 - int requestLimit = (limit == null || limit < 1 || limit > 1000) ? 1000 : (int)limit; - string url = "/public?command=returnTradeHistory¤cyPair=" + marketSymbol + "&limit=" + requestLimit ; - - //JToken obj = await MakeJsonRequestAsync($"/aggTrades?symbol={marketSymbol}&limit={maxRequestLimit}"); - JToken obj = await MakeJsonRequestAsync(url); - - //JToken obj = await MakeJsonRequestAsync("/public/trades/" + marketSymbol + "?limit=" + maxRequestLimit + "?sort=DESC"); - if(obj.HasValues) { // - foreach(JToken token in obj) { - var trade = token.ParseTrade("amount", "rate", "type", "date", TimestampType.Iso8601UTC, "globalTradeID"); - trades.Add(trade); - } - } + //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. + // [ + // { + // "id": "194", + // "price": "1.9", + // "quantity": "110", + // "amount": "209.00", + // "takerSide": "SELL", + // "ts": 1648690080545, + // "createTime": 1648634905695 + // } + // ] + + limit = (limit == null || limit < 1 || limit > 1000) ? 1000 : limit; + + var tradesResponse = await MakeJsonRequestAsync($"/markets/{marketSymbol}/trades?limit={limit}"); + + var trades = tradesResponse + .Select(t => + t.ParseTrade( + amountKey: "amount", priceKey: "price", typeKey: "takerSide", + timestampKey: "ts", TimestampType.UnixMilliseconds, idKey: "id", + typeKeyIsBuyValue: "BUY")).ToList(); + return trades; } - protected override async Task OnGetHistoricalTradesAsync(Func, bool> callback, string marketSymbol, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) - { - // [{"globalTradeID":245321705,"tradeID":11501281,"date":"2017-10-20 17:39:17","type":"buy","rate":"0.01022188","amount":"0.00954454","total":"0.00009756"},...] - ExchangeHistoricalTradeHelper state = new ExchangeHistoricalTradeHelper(this) - { - Callback = callback, - EndDate = endDate, - MillisecondGranularity = false, - ParseFunction = (JToken token) => token.ParseTrade("amount", "rate", "type", "date", TimestampType.Iso8601UTC, "globalTradeID"), - StartDate = startDate, - MarketSymbol = marketSymbol, - TimestampFunction = (DateTime dt) => ((long)CryptoUtility.UnixTimestampFromDateTimeSeconds(dt)).ToStringInvariant(), - Url = "/public?command=returnTradeHistory¤cyPair=[marketSymbol]&start={0}&end={1}" - }; - await state.ProcessHistoricalTrades(); - } - protected override async Task> OnGetCandlesAsync(string marketSymbol, int periodSeconds, DateTime? startDate = null, DateTime? endDate = null, int? limit = null) { - if (limit != null) - { - throw new APIException("Limit parameter not supported"); - } - - // https://poloniex.com/public?command=returnChartData¤cyPair=BTC_XMR&start=1405699200&end=9999999999&period=14400 - // [{"date":1405699200,"high":0.0045388,"low":0.00403001,"open":0.00404545,"close":0.00435873,"volume":44.34555992,"quoteVolume":10311.88079097,"weightedAverage":0.00430043}] - string url = "/public?command=returnChartData¤cyPair=" + marketSymbol; - if (startDate != null) - { - url += "&start=" + (long)startDate.Value.UnixTimestampFromDateTimeSeconds(); - } - url += "&end=" + (endDate == null ? long.MaxValue.ToStringInvariant() : ((long)endDate.Value.UnixTimestampFromDateTimeSeconds()).ToStringInvariant()); - url += "&period=" + periodSeconds.ToStringInvariant(); - JToken token = await MakeJsonRequestAsync(url); - List candles = new List(); - foreach (JToken candle in token) - { - candles.Add(this.ParseCandle(candle, marketSymbol, periodSeconds, "open", "high", "low", "close", "date", TimestampType.UnixSeconds, "quoteVolume", "volume", "weightedAverage")); - } - return candles; + //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 amounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - JToken result = await MakePrivateAPIRequestAsync("returnCompleteBalances"); - foreach (JProperty child in result.Children()) - { - decimal amount = child.Value["available"].ConvertInvariant(); - if (amount > 0m) - { - amounts[child.Name] = amount; - } - } - return amounts; + // Dictionary payload = await GetNoncePayloadAsync(); + Dictionary payload = new Dictionary(); + var response = await MakeJsonRequestAsync("/accounts/balances", payload: payload); + return null; } protected override async Task> OnGetAmountsAvailableToTradeAsync() @@ -1070,6 +1091,13 @@ private static bool TryPopulateAddressAndTag(string currency, IReadOnlyDictionar } + private static bool ParsePairState(string state) + { + if (string.IsNullOrWhiteSpace(state)) return false; + + return state == "NORMAL"; + } + /// /// Create a deposit address /// diff --git a/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs b/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs index ba9297d1..0ff4d961 100644 --- a/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs +++ b/src/ExchangeSharp/API/Exchanges/_Base/ExchangeAPIExtensions.cs @@ -351,6 +351,46 @@ public static async Task PlaceSafeMarketOrderAsync(this Exc return result; } + /// Common order book parsing method, some exchanges (like Poloniex) use single array for the whole depth (price and qty) like + /// "asks" : [ "24500.00", "0.024105", "24513.16", "0.611916", "24514.27", "0.001987"] + /// Token + /// Asks key + /// Bids key + /// Timestamp or sequence number + /// Order book + internal static ExchangeOrderBook ParseOrderBookFromJTokenArray + ( + this JToken token, + string asks = "asks", + string bids = "bids", + string sequence = "ts" + ) + { + var book = new ExchangeOrderBook { SequenceId = (token[sequence] ?? throw new InvalidOperationException()).ConvertInvariant() }; + var asksCount = (token[asks] ?? throw new InvalidOperationException()).Count(); + var bidsCount = (token[bids] ?? throw new InvalidOperationException()).Count(); + + for (var i = 0; i < asksCount; i++) + { + if (i % 2 != 0) continue; + var price = token[asks][i].Value(); + var amount = token[asks][i+1].Value(); + var depth = new ExchangeOrderPrice { Price = price, Amount = amount }; + book.Asks[depth.Price] = depth; + } + + for (var i = 0; i < bidsCount; i++) + { + if (i % 2 != 0) continue; + var price = token[bids][i].Value(); + var amount = token[bids][i+1].Value(); + var depth = new ExchangeOrderPrice { Price = price, Amount = amount }; + book.Bids[depth.Price] = depth; + } + + return book; + } + /// Common order book parsing method, most exchanges use "asks" and "bids" with /// arrays of length 2 for price and amount (or amount and price) /// Token diff --git a/src/ExchangeSharp/Utility/CryptoUtility.cs b/src/ExchangeSharp/Utility/CryptoUtility.cs index 6b968352..2b93776d 100644 --- a/src/ExchangeSharp/Utility/CryptoUtility.cs +++ b/src/ExchangeSharp/Utility/CryptoUtility.cs @@ -1219,6 +1219,48 @@ public static string SecondsToPeriodStringLong(int seconds) return seconds + "sec"; } + /// + /// Convert seconds to a period string, i.e. SECOND_5, MINUTE_1, HOUR_2, DAY_3, WEEK_1week, MONTH_1, YEAR_1 etc. + /// + /// Seconds. Use 60 for minute, 3600 for hour, 3600*24 for day, 3600*24*30 for month. + /// Period string + public static string SecondsToPeriodStringLongReverse(int seconds) + { + const int minuteThreshold = 60; + const int hourThreshold = 60 * 60; + const int dayThreshold = 60 * 60 * 24; + const int weekThreshold = dayThreshold * 7; + const int monthThreshold = dayThreshold * 30; + const int yearThreshold = monthThreshold * 12; + + if (seconds >= yearThreshold) + { + return $"YEAR_{seconds / yearThreshold}"; + } + if (seconds >= monthThreshold) + { + return $"MONTH_{seconds / monthThreshold}"; + } + if (seconds >= weekThreshold) + { + return $"WEEK_{seconds / weekThreshold}"; + } + if (seconds >= dayThreshold) + { + return $"DAY_{seconds / dayThreshold}"; + } + if (seconds >= hourThreshold) + { + return $"HOUR_{seconds / hourThreshold}"; + } + if (seconds >= minuteThreshold) + { + return $"MINUTE_{seconds / minuteThreshold}"; + } + + return $"SECOND_{seconds}"; + } + /// /// Load protected data as strings from file. Call this function in your production environment, loading in a securely encrypted file which will stay encrypted in memory. /// @@ -1363,6 +1405,29 @@ public static decimal CalculatePrecision(string numberWithDecimals) return (decimal)Math.Pow(10, -1 * precision); } + + /// + /// Precision to step size. + /// For example, precision of 5 would return a step size of 0.00001 + /// + public static decimal PrecisionToStepSize(decimal precision) + { + var sb = new StringBuilder(); + sb.Append("0"); + if (precision > 0) sb.Append("."); + if (precision == 1) + { + sb.Append("1"); + return decimal.Parse(sb.ToStringInvariant()); + } + for (var i = 0; i < precision; i++) + { + sb.Append(i + 1 == precision ? "1" : "0"); + } + + return decimal.Parse(sb.ToStringInvariant()); + } + /// /// Make a task execute synchronously - do not call this from the UI thread or it will lock up the application /// You should almos always use async / await instead